codex-overleaf-link 2.5.1 → 2.6.5

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 (111) 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 +148 -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 +46 -10
  9. package/extension/src/content/composerPanel.js +92 -12
  10. package/extension/src/content/contentRuntime.js +1114 -321
  11. package/extension/src/content/contextTray.js +8 -2
  12. package/extension/src/content/contextUsageView.js +267 -0
  13. package/extension/src/content/diagnosticsController.js +77 -34
  14. package/extension/src/content/diagnosticsExport.js +1 -1
  15. package/extension/src/content/diagnosticsPanel.js +147 -42
  16. package/extension/src/content/generated/content.bundle.js +5761 -1237
  17. package/extension/src/content/generated/content.bundle.meta.json +6 -3
  18. package/extension/src/content/legacyGlobalRegistry.js +4 -0
  19. package/extension/src/content/localSkillsPanel.js +6 -4
  20. package/extension/src/content/markdownDomRenderer.js +5 -3
  21. package/extension/src/content/markdownText.js +53 -14
  22. package/extension/src/content/modelPicker.js +29 -19
  23. package/extension/src/content/nativeCompatibilityController.js +52 -19
  24. package/extension/src/content/otWarmMirror.js +1 -1
  25. package/extension/src/content/pageBridgeClient.js +7 -7
  26. package/extension/src/content/panelMaintenance.js +10 -2
  27. package/extension/src/content/panelRenderer.js +55 -28
  28. package/extension/src/content/pendingInputView.js +1 -1
  29. package/extension/src/content/projectSettingsCoordinator.js +30 -6
  30. package/extension/src/content/providerSettingsDialog.js +28 -19
  31. package/extension/src/content/recentProjects.js +30 -19
  32. package/extension/src/content/runActivitySummary.js +122 -14
  33. package/extension/src/content/runCompletionMeta.js +194 -6
  34. package/extension/src/content/runController.js +2 -1
  35. package/extension/src/content/runFailureNotice.js +8 -1
  36. package/extension/src/content/runPresence.js +18 -8
  37. package/extension/src/content/runQueueScheduler.js +27 -13
  38. package/extension/src/content/runRecoveryActions.js +2 -1
  39. package/extension/src/content/runResultActions.js +73 -24
  40. package/extension/src/content/runReviewController.js +102 -37
  41. package/extension/src/content/runTimelineView.js +224 -25
  42. package/extension/src/content/scopedPersistencePanelState.js +124 -46
  43. package/extension/src/content/selectionContextView.js +30 -3
  44. package/extension/src/content/sensitivePreflight.js +14 -1
  45. package/extension/src/content/sessionManager.js +49 -3
  46. package/extension/src/content/sessionMenuView.js +54 -18
  47. package/extension/src/content/sessionPersistence.js +25 -5
  48. package/extension/src/content/settingsPanel.js +15 -2
  49. package/extension/src/content/settingsWorkbench.js +127 -89
  50. package/extension/src/content/sharedSessionViewSync.js +13 -4
  51. package/extension/src/content/subagentActivityView.js +23 -7
  52. package/extension/src/content/updateNotice.js +60 -6
  53. package/extension/src/content/writebackOrchestrator.js +301 -72
  54. package/extension/src/content/writingStyleSettings.js +70 -17
  55. package/extension/src/page/compileBridge.js +193 -180
  56. package/extension/src/page/overleafCapabilities.js +4 -1
  57. package/extension/src/page/overleafEditor.js +12 -3
  58. package/extension/src/page/overleafProjectSnapshot.js +56 -3
  59. package/extension/src/page/snapshotRouter.js +75 -66
  60. package/extension/src/page/textCoordinates.js +17 -1
  61. package/extension/src/page/textFileCreator.js +9 -1
  62. package/extension/src/page/treeOperations.js +200 -19
  63. package/extension/src/page/writebackRouter.js +172 -113
  64. package/extension/src/pageBridge.js +124 -73
  65. package/extension/src/popup.js +1 -1
  66. package/extension/src/shared/agentTranscript.js +372 -18
  67. package/extension/src/shared/auditRecords.js +3 -0
  68. package/extension/src/shared/compatibility.js +1 -1
  69. package/extension/src/shared/compileAdapter.js +58 -17
  70. package/extension/src/shared/composerAttachmentDraft.js +27 -7
  71. package/extension/src/shared/contextUsage.js +125 -0
  72. package/extension/src/shared/failureReasons.js +57 -3
  73. package/extension/src/shared/i18n.js +315 -29
  74. package/extension/src/shared/lineReferences.js +38 -31
  75. package/extension/src/shared/pageRpcContract.js +1 -1
  76. package/extension/src/shared/panelIcons.js +39 -0
  77. package/extension/src/shared/projectFiles.js +2 -1
  78. package/extension/src/shared/reviewing.js +130 -1
  79. package/extension/src/shared/runActivityModel.js +70 -4
  80. package/extension/src/shared/runExecutionSnapshot.js +13 -0
  81. package/extension/src/shared/scopedPersistenceBrowserAdapter.js +34 -0
  82. package/extension/src/shared/sessionState.js +63 -4
  83. package/extension/src/shared/sessionTitle.js +14 -1
  84. package/extension/src/shared/sharedSessionBridge.js +81 -13
  85. package/extension/src/shared/staleGuard.js +130 -63
  86. package/extension/src/shared/storageDb.js +35 -3
  87. package/extension/src/shared/storageMigration.js +111 -2
  88. package/extension/src/shared/storageRunActions.js +62 -8
  89. package/extension/src/shared/undoOperations.js +126 -28
  90. package/extension/src/shared/updateRuntimeIdentity.js +16 -2
  91. package/extension/src/shared/writebackSettlement.js +21 -3
  92. package/extension/styles/panel.css +1293 -549
  93. package/native-host/src/bootstrapUpdate.js +52 -16
  94. package/native-host/src/chatBridgeRequest.js +60 -6
  95. package/native-host/src/chatBridgeResponse.js +10 -1
  96. package/native-host/src/chatCompletionsBridge.js +29 -2
  97. package/native-host/src/codexPromptAssembly.js +86 -118
  98. package/native-host/src/codexSessionRunner.js +127 -17
  99. package/native-host/src/debugLog.js +27 -3
  100. package/native-host/src/index.js +42 -11
  101. package/native-host/src/localSkills.js +1 -1
  102. package/native-host/src/mirrorWorkspace.js +47 -3
  103. package/native-host/src/nativeEnvironment.js +27 -5
  104. package/native-host/src/providerContextError.js +20 -0
  105. package/native-host/src/providerRuntime.js +1 -0
  106. package/native-host/src/skills/reviewer-response/SKILL.md +65 -0
  107. package/native-host/src/taskRunnerRuntime.js +28 -8
  108. package/native-host/src/textPatch.js +473 -377
  109. package/native-host/src/updateManager.js +144 -21
  110. package/native-host/src/writingStyleRuntime.js +1 -1
  111. package/package.json +5 -8
@@ -42,10 +42,15 @@
42
42
  reason: status === 'completed' ? 'completed' : 'continue_queue'
43
43
  })
44
44
  );
45
- } else if (!shouldContinue) {
46
- const paused = originalQueue.length
47
- ? Queue.pauseAll(originalQueue, status || 'run_did_not_complete')
48
- : originalQueue;
45
+ }
46
+ const canStart = shouldContinue ? options.canStart?.(sessionId) : true;
47
+ const latestQueue = options.getQueue?.(sessionId) || originalQueue;
48
+ const inactive = shouldContinue && canStart !== 'session_transition'
49
+ && (canStart === false || options.isRunning?.())
50
+ && latestQueue.some(item => item?.status === 'queued');
51
+ if (!shouldContinue || inactive) {
52
+ const reason = inactive ? 'session_inactive' : status || 'run_did_not_complete';
53
+ const paused = latestQueue.length ? Queue.pauseAll(latestQueue, reason) : latestQueue;
49
54
  options.setQueue?.(sessionId, paused);
50
55
  try {
51
56
  await options.persist?.({
@@ -55,19 +60,23 @@
55
60
  const latestQueue = options.getQueue?.(sessionId) || paused;
56
61
  options.setQueue?.(
57
62
  sessionId,
58
- Queue.pauseAll(latestQueue, status || 'run_did_not_complete')
63
+ Queue.pauseAll(latestQueue, reason)
59
64
  );
60
65
  throw error;
61
66
  }
62
67
  }
63
68
  options.onChange?.(sessionId);
64
- if (shouldContinue) {
69
+ if (shouldContinue && !inactive) {
65
70
  scheduleOne(sessionId);
66
71
  }
67
72
  }
68
73
 
69
74
  function scheduleOne(sessionId) {
70
- if (scheduling || options.isRunning?.() || options.canStart?.(sessionId) === false) {
75
+ if (scheduling || options.isRunning?.()) {
76
+ return false;
77
+ }
78
+ const canStart = options.canStart?.(sessionId);
79
+ if (canStart === false || canStart === 'session_transition') {
71
80
  return false;
72
81
  }
73
82
  const initialQueue = options.getQueue?.(sessionId) || [];
@@ -195,7 +204,7 @@
195
204
  setQueue: (sessionId, pendingInputs) => options.setQueue?.(sessionId, pendingInputs),
196
205
  persist: persistenceOptions => options.save?.(persistenceOptions),
197
206
  isRunning: () => Boolean(options.getCurrentRun?.()),
198
- canStart: sessionId => options.canStart?.(sessionId) !== false,
207
+ canStart: sessionId => options.canStart?.(sessionId),
199
208
  start: (item, sessionId) => startQueuedInput(item, sessionId),
200
209
  onChange: () => render(),
201
210
  onDequeued: (item, context) => options.removeGuidance?.(item.id, {
@@ -444,12 +453,17 @@
444
453
  }
445
454
 
446
455
  async function startQueuedInput(item, sessionId) {
447
- if (options.canStart?.(sessionId) === false || options.getCurrentRun?.()) {
448
- const error = new Error('Queued input cannot start in the current session.');
449
- error.code = 'queue_session_not_active';
450
- throw error;
451
- }
456
+ const assertCanStart = () => {
457
+ const canStart = options.canStart?.(sessionId);
458
+ if (canStart === false || canStart === 'session_transition' || options.getCurrentRun?.()) {
459
+ const error = new Error('Queued input cannot start in the current session.');
460
+ error.code = 'queue_session_not_active';
461
+ throw error;
462
+ }
463
+ };
464
+ assertCanStart();
452
465
  await options.applyQueuedInput?.(item);
466
+ assertCanStart();
453
467
  await options.run?.(item);
454
468
  }
455
469
 
@@ -22,7 +22,7 @@
22
22
 
23
23
  const RETRYABLE_FAILURE_CODES = new Set([
24
24
  'native_request_failed', 'codex_timeout', 'codex_no_usable_result',
25
- 'codex_output_limit', 'stale_source_changed', 'patch_anchor_not_found',
25
+ 'codex_output_limit', 'codex_context_limit', 'stale_source_changed', 'patch_anchor_not_found',
26
26
  'target_editor_not_ready', 'write_timeout', 'partial_write_needs_review',
27
27
  'write_operation_failed'
28
28
  ]);
@@ -56,6 +56,7 @@
56
56
  const retry = buildRecoveryButton(actionKey,
57
57
  tx('Edit & resend', '编辑后重发'),
58
58
  tx('Put this run\u2019s task back into the composer so you can adjust and resend it.', '把本轮任务填回输入框,修改后可直接重发。'));
59
+ retry.dataset.recoveryAction = 'edit-resend';
59
60
  retry.addEventListener('click', clickEvent => {
60
61
  clickEvent.stopPropagation();
61
62
  refillComposerForRetry(run || null);
@@ -4,13 +4,12 @@
4
4
  })(typeof window !== 'undefined' ? window : globalThis, function (RunCompletionMeta) {
5
5
  'use strict';
6
6
 
7
- const { splitFlatCompletionReport, projectCompletionBody, projectCompletionMeta, summarizeCompletionMeta } = RunCompletionMeta;
7
+ const { splitFlatCompletionReport, projectNotStartedRun, projectFinishedRun, projectCompletionConclusion, projectCompletionBody, projectCompletionMeta, summarizeCompletionMeta } = RunCompletionMeta;
8
8
 
9
9
 
10
10
  function create(deps = {}) {
11
11
  const {
12
12
  tr,
13
- getLocale,
14
13
  forkRunFromNode,
15
14
  document: documentRef = globalThis.document,
16
15
  navigator: navigatorRef = globalThis.navigator
@@ -21,11 +20,27 @@
21
20
  const reports = root.querySelectorAll('.run-completion-report');
22
21
  const report = reports[reports.length - 1];
23
22
  if (!actions || !report) return;
24
- report.append(actions);
23
+ // Share one row with the facts summary when it exists; the expandable
24
+ // details list stays below both.
25
+ const factsToggle = report.querySelector(':scope > .run-final-answer__facts > .run-final-answer__facts-sum');
26
+ if (factsToggle) factsToggle.after(actions);
27
+ else {
28
+ // Without a facts summary, the report's own Details toggle shares the row.
29
+ const details = report.querySelector(':scope > .run-outcome-details, :scope > .run-outcome-footer > .run-outcome-details');
30
+ if (details) {
31
+ let footer = details.parentElement.matches('.run-outcome-footer') ? details.parentElement : null;
32
+ if (!footer) {
33
+ footer = documentRef.createElement('div');
34
+ footer.className = 'run-outcome-footer';
35
+ details.before(footer);
36
+ footer.append(details);
37
+ }
38
+ footer.append(actions);
39
+ } else report.append(actions);
40
+ }
25
41
  actions.hidden = false;
26
42
  configureCopyButton(root);
27
43
  configureForkButton(root, run);
28
- configureCompletedTime(actions, run);
29
44
  }
30
45
 
31
46
  function configureForkButton(root, run) {
@@ -40,7 +55,7 @@
40
55
  button.disabled = !available;
41
56
  button.title = tr(available ? 'forkRunTitle' : 'forkRunUnavailable');
42
57
  button.setAttribute('aria-label', button.title);
43
- button.innerHTML = '<svg viewBox="0 0 20 20" aria-hidden="true"><path d="M3 10h3.5c3.5 0 4.2-4 7.5-4h2m-2.5-2.5L16 6l-2.5 2.5M6.5 10c3.5 0 4.2 4 7.5 4h2m-2.5-2.5L16 14l-2.5 2.5"/></svg>';
58
+ button.innerHTML = '<svg viewBox="0 0 20 20" aria-hidden="true"><path transform="translate(.5 0)" d="M3 10h3.5c3.5 0 4.2-4 7.5-4h2m-2.5-2.5L16 6l-2.5 2.5M6.5 10c3.5 0 4.2 4 7.5 4h2m-2.5-2.5L16 14l-2.5 2.5"/></svg>';
44
59
  if (!available) return;
45
60
  button.addEventListener('click', async event => {
46
61
  event.stopPropagation();
@@ -55,34 +70,22 @@
55
70
  });
56
71
  }
57
72
 
58
- function configureCompletedTime(actions, run) {
59
- const target = actions.querySelector('[data-run-completed-time]');
60
- const finishedAt = String(run.finishedAt || '').trim();
61
- const date = finishedAt ? new Date(finishedAt) : null;
62
- if (!target || !date || !Number.isFinite(date.getTime())) return;
63
- target.hidden = false;
64
- target.textContent = new Intl.DateTimeFormat(getLocale(), {
65
- hour: '2-digit',
66
- minute: '2-digit'
67
- }).format(date);
68
- target.title = date.toLocaleString(getLocale());
69
- }
70
-
71
73
  function configureCopyButton(root) {
72
74
  const existing = root.querySelector('[data-run-copy]');
73
75
  const button = existing.cloneNode(true);
74
76
  existing.replaceWith(button);
75
- const finalAnswer = root.querySelector('.run-completion-report .run-final-answer');
76
- const copyText = String(finalAnswer?.innerText || finalAnswer?.textContent || '').trim();
77
+ const available = Boolean(readConclusionText(root).trim());
77
78
  button.hidden = false;
78
- button.disabled = !copyText;
79
+ button.disabled = !available;
79
80
  button.title = tr('copyConclusionTitle');
80
81
  button.setAttribute('aria-label', button.title);
81
- button.innerHTML = '<svg viewBox="0 0 20 20" aria-hidden="true"><rect x="6.5" y="6.5" width="9" height="9" rx="2"/><path d="M13.5 6.5V5a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v6.5a2 2 0 0 0 2 2h1.5"/></svg>';
82
- if (!copyText) return;
82
+ button.innerHTML = '<svg viewBox="0 0 20 20" aria-hidden="true"><g transform="translate(.75 .75)"><rect x="6.5" y="6.5" width="9" height="9" rx="2"/><path d="M13.5 6.5V5a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v6.5a2 2 0 0 0 2 2h1.5"/></g></svg>';
83
+ if (!available) return;
83
84
  button.addEventListener('click', async event => {
84
85
  event.stopPropagation();
85
86
  try {
87
+ const copyText = readConclusionText(root);
88
+ if (!copyText.trim()) return;
86
89
  await writeClipboardText(copyText);
87
90
  button.title = tr('copyConclusionDone');
88
91
  } catch (_error) {
@@ -92,6 +95,52 @@
92
95
  });
93
96
  }
94
97
 
98
+ function readConclusionText(root) {
99
+ const answer = root.querySelector('.run-completion-report .run-final-answer');
100
+ if (!answer) return '';
101
+ return extractConclusionText(answer).replace(/\u200b/g, '').replace(/[ \t]+$/gm, '').trim();
102
+ }
103
+
104
+ function extractConclusionText(node) {
105
+ if (node.nodeType === 3) return String(node.textContent || '').replace(/\s+/g, ' ');
106
+ const tag = node.tagName?.toLowerCase();
107
+ if (tag === 'br') return '\n';
108
+ if (tag === 'pre' || tag === 'code') return node.textContent || '';
109
+ if (node.classList?.contains('run-math')) {
110
+ const source = node.dataset.mathRendered === 'false'
111
+ ? node.textContent || ''
112
+ : node.querySelector('annotation[encoding="application/x-tex"]')?.textContent || '';
113
+ const delimiter = node.dataset.mathDisplay === 'block' ? '$$' : '$';
114
+ return source ? `${delimiter}${source}${delimiter}` : '';
115
+ }
116
+ const children = Array.from(node.childNodes || []);
117
+ if (tag === 'ul' || tag === 'ol') {
118
+ const start = Number(node.getAttribute('start') || 1);
119
+ return children.filter(child => child.tagName?.toLowerCase() === 'li')
120
+ .map((child, index) => `${tag === 'ol' ? `${start + index}. ` : '- '}${extractConclusionText(child)}`)
121
+ .join('\n');
122
+ }
123
+ if (tag === 'table') {
124
+ return Array.from(node.querySelectorAll('tr'), row => Array.from(row.children,
125
+ cell => extractConclusionText(cell)).join('\t')).join('\n');
126
+ }
127
+ let text = '';
128
+ let previousBlock = false;
129
+ for (const child of children) {
130
+ const value = extractConclusionText(child);
131
+ if (!value) continue;
132
+ const block = /^(p|h[1-6]|blockquote|ul|ol|pre|table|div)$/.test(child.tagName?.toLowerCase())
133
+ || child.dataset?.mathDisplay === 'block';
134
+ if (text && (previousBlock || block)) {
135
+ const newlines = (text.match(/\n*$/)?.[0].length || 0) + (value.match(/^\n*/)?.[0].length || 0);
136
+ text += '\n'.repeat(Math.max(0, 2 - newlines));
137
+ }
138
+ text += value;
139
+ previousBlock = block;
140
+ }
141
+ return text;
142
+ }
143
+
95
144
  async function writeClipboardText(text) {
96
145
  if (navigatorRef?.clipboard?.writeText) {
97
146
  await navigatorRef.clipboard.writeText(text);
@@ -109,7 +158,7 @@
109
158
  if (!copied) throw new Error('clipboard_copy_failed');
110
159
  }
111
160
 
112
- return Object.freeze({ configureResultActions, projectUndoAvailability, splitFlatCompletionReport, projectCompletionBody, projectCompletionMeta, summarizeCompletionMeta });
161
+ return Object.freeze({ configureResultActions, projectUndoAvailability, splitFlatCompletionReport, projectNotStartedRun, projectFinishedRun, projectCompletionConclusion, projectCompletionBody, projectCompletionMeta, summarizeCompletionMeta });
113
162
  }
114
163
 
115
164
 
@@ -58,9 +58,12 @@
58
58
  if (Modules.StorageDb?.sharedSessionsEnabled?.() !== true) return;
59
59
  findRunRecord(runId).undoOwnerProtocol = 1;
60
60
  try { await flushQueuedSaveState({ requireDurable: true, reviewRunIds: [runId] }); }
61
- catch (cause) { throw Object.assign(new Error('The Undo owner marker could not be durably saved.'), { code: 'review_state_not_persisted', cause }); }
61
+ catch (cause) {
62
+ if (cause.code === 'shared_session_conflict' && cause.durableReviewRunIds?.includes(runId)) notify?.(cause.message);
63
+ else throw Object.assign(new Error('The Undo owner marker could not be durably saved.'), { code: 'review_state_not_persisted', cause });
64
+ }
62
65
  reviewOwners.get(runId)?.();
63
- const saved = await Modules.StorageDb.getRecord('sessions', getState().activeSessionId);
66
+ const saved = await Modules.StorageDb.getRecord('sessions', getState().activeSessionId, { adopt: false });
64
67
  if (saved?.runs?.find(run => run.id === runId)?.undoOwnerProtocol !== 1)
65
68
  throw Object.assign(new Error('The Undo owner marker was not durably retained.'), { code: 'review_state_not_persisted' });
66
69
  reviewOwners.get(runId)?.();
@@ -68,7 +71,7 @@
68
71
  async function withSharedReviewOwnership(runId, work) {
69
72
  const db = Modules.StorageDb;
70
73
  if (db?.sharedSessionsEnabled?.() !== true) return work();
71
- const run = findRunRecord(runId);
74
+ let run = findRunRecord(runId);
72
75
  if (!run || trackedChangeInFlight.has(runId)) return;
73
76
  const assertCurrent = undoOwner(run);
74
77
  const sessionId = getState().activeSessionId;
@@ -77,8 +80,9 @@
77
80
  reviewOwners.set(runId, assertOwner);
78
81
  try {
79
82
  assertCurrent();
80
- const session = await db.getRecord('sessions', sessionId);
81
- assertCurrent();
83
+ // Only the run is refreshed here; keep the visible session's merge baseline.
84
+ const session = await db.getRecord('sessions', sessionId, { adopt: false });
85
+ run = assertCurrent();
82
86
  const latest = session?.runs?.find(record => record.id === runId);
83
87
  if (!latest || session.projectId !== getRunProjectIdForWriteback(run) || session.accountScopeId !== getAccountScopeId())
84
88
  throw Object.assign(new Error('The shared Undo run is no longer available in its original scope.'), { code: 'review_context_changed' });
@@ -162,9 +166,10 @@
162
166
  trackedChangeInFlight.set(runId, 'reject'); refreshRunCardControls(runId);
163
167
  try { return await work(); }
164
168
  catch (error) {
165
- let current = true; try { assertOwner(); } catch (_stale) { current = false; }
166
- if (current && findRunRecord(runId) === run) {
167
- if (run.undoStatus === 'running') setRunUndoStatus(runId, 'partial');
169
+ let current; try { current = assertOwner(); } catch (_stale) {}
170
+ const durableReviewConflict = error.code === 'shared_session_conflict' && error.durableReviewRunIds?.includes(runId);
171
+ if (current && error.code !== 'review_context_changed' && !durableReviewConflict) {
172
+ if (current.undoStatus === 'running') setRunUndoStatus(runId, 'partial');
168
173
  appendRunRecordEvent(runId, { title: tr('undoNoTraceTitle'), status: 'failed',
169
174
  detail: { [tr('detailReason')]: error.message } });
170
175
  }
@@ -174,18 +179,59 @@
174
179
  } finally { trackedChangeInFlight.delete(runId); refreshRunCardControls(runId); }
175
180
  }
176
181
  const withUndoLock = (runId, work) => withSharedReviewOwnership(runId, () => withLocalUndoLock(runId, work));
177
- const undoRun = runId => withUndoLock(runId, () => executeUndo(runId));
182
+ // Undo restores the source; with Auto compile on, refresh the PDF the way a write does.
183
+ async function recompileAfterUndo(runId, work) {
184
+ const before = findRunRecord(runId);
185
+ const prior = `${before?.undoStatus || ''}|${before?.trackedChangeStatus || ''}`;
186
+ const result = await work();
187
+ const run = findRunRecord(runId);
188
+ const undone = ['applied', 'partial'].includes(run?.undoStatus) || run?.trackedChangeStatus === 'rejected';
189
+ if (undone && `${run.undoStatus || ''}|${run.trackedChangeStatus || ''}` !== prior
190
+ && getState()?.autoRecompile !== false && run.runProjectId && run.runProjectId === getCurrentProjectId?.()) {
191
+ Promise.resolve(callPageBridge('triggerCompile', { preferUiClick: true, waitForSaveMs: 5000,
192
+ runProjectId: run.runProjectId })).catch(() => {});
193
+ }
194
+ return result;
195
+ }
196
+ // Inside the lock: a stale page that adopts another page's finished Undo never reaches its work.
197
+ const undoRun = runId => withUndoLock(runId, () => recompileAfterUndo(runId, () => executeUndo(runId)));
178
198
  const acceptRun = runId => withSharedReviewOwnership(runId, () => executeAcceptRun(runId));
179
- const undoRunTrackedChanges = (runId, run) => withUndoLock(runId, () => executeTrackedUndo(runId, run));
199
+ const undoRunTrackedChanges = (runId, run) => withUndoLock(runId,
200
+ () => recompileAfterUndo(runId, () => executeTrackedUndo(runId, run)));
180
201
 
181
202
  function undoOwner(run) {
182
- const captured = getState(), sessionId = captured.activeSessionId;
203
+ const sessionId = getState().activeSessionId;
183
204
  const scope = [getCurrentProjectId?.(), getAccountScopeId?.(), getRouteGeneration?.()];
205
+ const provenance = value => JSON.stringify([value?.id, value?.runProjectId || '', value?.startedAt || '',
206
+ value?.runAccountScopeId || scope[1] || '', value?.nativeEventSeq || 0, value?.codexThreadId || '', value?.codexTurnId || '', value?.executionSnapshot || null]);
207
+ const sessionScope = () => {
208
+ const session = getState().sessions?.find(item => item.id === sessionId);
209
+ return JSON.stringify([session?.projectId || '', session?.accountScopeId || '']);
210
+ };
211
+ const source = provenance(run), owningSession = sessionScope();
184
212
  return () => {
185
213
  reviewOwners.get(run.id)?.();
186
- if (getState() !== captured || captured.activeSessionId !== sessionId || findRunRecord(run.id) !== run
214
+ const live = findRunRecord(run.id);
215
+ if (getState().activeSessionId !== sessionId || sessionScope() !== owningSession || !live
216
+ || !(live === run || Modules.SessionState?.sameRunIdentity?.(run, live)) || provenance(live) !== source
187
217
  || JSON.stringify(scope) !== JSON.stringify([getCurrentProjectId?.(), getAccountScopeId?.(), getRouteGeneration?.()]))
188
218
  throw Object.assign(new Error('The Undo project, account or session changed.'), { code: 'review_context_changed' });
219
+ return live;
220
+ };
221
+ }
222
+
223
+ function checkpointGuard(run, assertOwner) {
224
+ const snapshot = value => {
225
+ const normalized = Modules.SessionState?.normalizeRuns?.([value])?.[0] || value;
226
+ return JSON.stringify(['appliedOperations', 'undoOperations', 'undoBaseFiles', 'undoExpectedFiles',
227
+ 'undoTrackedChanges', 'trackedChangeCaptures', 'undoStatus', 'trackedChangeStatus', 'forkSnapshot']
228
+ .map(key => normalized[key]));
229
+ };
230
+ const expected = snapshot(run);
231
+ return () => {
232
+ const live = assertOwner();
233
+ if (snapshot(live) !== expected) throw Object.assign(new Error('The Undo project, account or session changed.'), { code: 'review_context_changed' });
234
+ return live;
189
235
  };
190
236
  }
191
237
 
@@ -198,9 +244,12 @@ function undoOwner(run) {
198
244
  }
199
245
 
200
246
  async function executeUndo(runId) {
201
- const run = findRunRecord(runId);
247
+ let run = findRunRecord(runId);
202
248
  if (run) Object.assign(run, Modules.UndoOperations.prepareRunUndo(run));
249
+ const mirrorOwner = { projectId: getRunProjectIdForWriteback(run), runAccountScopeId: run?.runAccountScopeId || getAccountScopeId?.() || '',
250
+ sessionId: getState().activeSessionId };
203
251
  const assertCurrent = undoOwner(run);
252
+ let assertInputs = checkpointGuard(run, assertCurrent);
204
253
  if (!getRunUndoCount(run) || run.undoStatus === 'applied') {
205
254
  return;
206
255
  }
@@ -210,7 +259,7 @@ async function executeUndo(runId) {
210
259
  const pendingMirror = getPendingMirrorRefresh?.();
211
260
  if (pendingMirror) {
212
261
  await pendingMirror.catch(() => {});
213
- assertCurrent();
262
+ run = assertInputs();
214
263
  }
215
264
 
216
265
  if ((Array.isArray(run.undoTrackedChanges) && run.undoTrackedChanges.length) || hasTrackedEditorUndo(run)
@@ -272,8 +321,9 @@ async function executeUndo(runId) {
272
321
  }
273
322
  }
274
323
 
275
- assertCurrent();
324
+ run = assertInputs();
276
325
  const deadlineAt = await prepareUndoDispatch(runId);
326
+ run = assertInputs();
277
327
  setRunUndoStatus(runId, 'running');
278
328
  appendRunRecordEvent(runId, {
279
329
  title: tr('undoNoTraceStarted'),
@@ -281,7 +331,8 @@ async function executeUndo(runId) {
281
331
  detail: { [tr('detailWillUndo')]: formatOperationFiles(selectedOperations) }
282
332
  });
283
333
 
284
- await persistUndoOwnerMarker(runId); assertCurrent();
334
+ assertInputs = checkpointGuard(run, assertCurrent);
335
+ await persistUndoOwnerMarker(runId); run = assertInputs();
285
336
  const result = await callPageBridge('applyOperations', {
286
337
  operations: selectedOperations, deadlineAt, reviewOwnerToken: reviewOwners.get(runId)?.token,
287
338
  baseFiles: run.undoBaseFiles || [],
@@ -289,9 +340,11 @@ async function executeUndo(runId) {
289
340
  // Undo remains bound to its original project, including after navigation.
290
341
  runProjectId: getRunProjectIdForWriteback(run)
291
342
  });
292
- assertCurrent();
343
+ // The Page may have changed the original document even if this view moved
344
+ // while awaiting its reply. Fence that project's mirror before rechecking.
345
+ writebackOrchestrator.invalidateMirrorAfterUndo(runId, mirrorOwner.projectId, result, mirrorOwner);
346
+ run = assertInputs();
293
347
  const undoApplied = isUndoResultEffectivelyApplied(run, result);
294
- writebackOrchestrator.invalidateMirrorAfterUndo(runId, getRunProjectIdForWriteback(run), result);
295
348
  appendUndoReviewingPolicyEvent(runId, result.reviewingPolicy);
296
349
  appendRunRecordEvent(runId, {
297
350
  title: tr('undoResult', { applied: result.applied?.length || 0, skipped: result.skipped?.length || 0 }),
@@ -320,7 +373,10 @@ async function executeUndo(runId) {
320
373
  }
321
374
 
322
375
  async function executeTrackedUndo(runId, run) {
376
+ const mirrorOwner = { projectId: getRunProjectIdForWriteback(run), runAccountScopeId: run?.runAccountScopeId || getAccountScopeId?.() || '',
377
+ sessionId: getState().activeSessionId };
323
378
  const assertCurrent = undoOwner(run);
379
+ let assertInputs = checkpointGuard(run, assertCurrent);
324
380
  const fileUndoOperations = Modules.UndoOperations.getFileUndoOperations(run);
325
381
  const textRun = Modules.UndoOperations.textUndoRun(run);
326
382
  const trackedUndo = textRun.undoTrackedChanges.length > 0;
@@ -343,8 +399,9 @@ async function executeTrackedUndo(runId, run) {
343
399
  return;
344
400
  }
345
401
 
346
- assertCurrent();
402
+ run = assertInputs();
347
403
  const deadlineAt = await prepareUndoDispatch(runId);
404
+ run = assertInputs();
348
405
  // Keep the review lifecycle until every pending part has settled.
349
406
  // Its local lock stays active while verified recovery progress is persisted.
350
407
  const lifecycleReject = (trackedUndo || fileUndoOperations.length > 0) && isTrackedChangeLifecycleRun(run);
@@ -363,7 +420,8 @@ async function executeTrackedUndo(runId, run) {
363
420
 
364
421
  let result;
365
422
  try {
366
- await persistUndoOwnerMarker(runId); assertCurrent();
423
+ assertInputs = checkpointGuard(run, assertCurrent);
424
+ await persistUndoOwnerMarker(runId); run = assertInputs();
367
425
  const trackedUndoPostFilesAtDispatch = buildTrackedUndoPostFiles(textRun);
368
426
  result = hasTextUndo ? await callPageBridge('rejectTrackedChanges', {
369
427
  trackedChanges: textRun.undoTrackedChanges || [], deadlineAt, reviewOwnerToken: reviewOwners.get(runId)?.token,
@@ -375,12 +433,13 @@ async function executeTrackedUndo(runId, run) {
375
433
  // The page-side project guard still refuses writes after navigation.
376
434
  runProjectId: getRunProjectIdForWriteback(run)
377
435
  }) : { ok: true, applied: [], skipped: [] };
378
- assertCurrent();
436
+ run = assertInputs();
379
437
  if (hasTextUndo) WritebackSettlement.attachUndoNotVerifiedFailure(textRun, result, {
380
438
  buildFailure: buildContentFailure, postFiles: trackedUndoPostFilesAtDispatch });
381
439
  if (fileUndoOperations.length && lifecycleReject && hasTextUndo) {
382
440
  WritebackSettlement.attachUndoNotVerifiedFailure(textRun, result, { buildFailure: buildContentFailure, postFiles: trackedUndoPostFilesAtDispatch });
383
441
  await applyTrackedChangeSettlement(runId, 'reject', { ...result, ok: false }, { assertCurrent });
442
+ run = assertCurrent(); assertInputs = checkpointGuard(run, assertCurrent);
384
443
  }
385
444
  if (fileUndoOperations.length) {
386
445
  let createdResult;
@@ -399,8 +458,8 @@ async function executeTrackedUndo(runId, run) {
399
458
  operation, result: { ok: false, code: 'undo_operation_failed', reason: error.message }
400
459
  })) };
401
460
  }
402
- assertCurrent();
403
461
  result = Modules.UndoOperations.mergeFileUndoResult(result, createdResult, fileUndoOperations);
462
+ run = assertInputs();
404
463
  }
405
464
  appendRunRecordEvent(runId, {
406
465
  title: fileUndoOperations.length ? tr('undoResult', { applied: result.applied?.length || 0, skipped: result.skipped?.length || 0 }) : trackedUndo
@@ -431,28 +490,29 @@ async function executeTrackedUndo(runId, run) {
431
490
  await applyLegacyUndoSettlement(runId, result.ok === true && !result.skipped?.length ? 'applied' : 'partial', result);
432
491
  } finally {
433
492
  // Known Page changes require invalidation even when a durable checkpoint fails.
434
- if (result) writebackOrchestrator.invalidateMirrorAfterUndo(runId, getRunProjectIdForWriteback(run), result);
493
+ if (result) writebackOrchestrator.invalidateMirrorAfterUndo(runId, mirrorOwner.projectId, result, mirrorOwner);
435
494
  }
436
495
  }
437
496
 
438
497
  function reviewOwner(run) {
439
- const owner = { run, state: getState(), sessionId: getState().activeSessionId,
440
- projectId: getCurrentProjectId(), accountScopeId: getAccountScopeId(), generation: getRouteGeneration() };
498
+ const projectId = getCurrentProjectId(), accountScopeId = getAccountScopeId();
499
+ const assertRun = undoOwner(run);
441
500
  const assert = () => {
442
- reviewOwners.get(run.id)?.();
443
- if (!owner.projectId || !owner.accountScopeId || run.runProjectId !== owner.projectId
444
- || getCurrentProjectId() !== owner.projectId || getAccountScopeId() !== owner.accountScopeId
445
- || getRouteGeneration() !== owner.generation || getState() !== owner.state
446
- || getState().activeSessionId !== owner.sessionId || findRunRecord(run.id) !== run
447
- || (run.runAccountScopeId && run.runAccountScopeId !== owner.accountScopeId))
501
+ let live;
502
+ try { live = assertRun(); } catch (error) {
503
+ if (error.code === 'review_context_changed') error.message = 'The original review project, account or session changed.';
504
+ throw error;
505
+ }
506
+ if (!projectId || !accountScopeId || live.runProjectId !== projectId
507
+ || (live.runAccountScopeId && live.runAccountScopeId !== accountScopeId))
448
508
  throw Object.assign(new Error('The original review project, account or session changed.'), { code: 'review_context_changed' });
449
- return run;
509
+ return live;
450
510
  };
451
- assert(); return { ...owner, assert };
511
+ assert(); return { projectId, accountScopeId, assert };
452
512
  }
453
513
 
454
514
  async function executeAcceptRun(runId) {
455
- const run = findRunRecord(runId);
515
+ let run = findRunRecord(runId);
456
516
  if (!run || !isTrackedChangeLifecycleRun(run)) {
457
517
  return;
458
518
  }
@@ -471,6 +531,7 @@ async function executeAcceptRun(runId) {
471
531
  }
472
532
 
473
533
  const owner = reviewOwner(run);
534
+ const assertInputs = checkpointGuard(run, owner.assert);
474
535
  trackedChangeInFlight.set(runId, 'accept');
475
536
  refreshRunCardControls(runId);
476
537
  appendRunRecordEvent(runId, {
@@ -481,6 +542,7 @@ async function executeAcceptRun(runId) {
481
542
 
482
543
  try {
483
544
  const deadlineAt = await prepareUndoDispatch(runId);
545
+ run = assertInputs();
484
546
  const result = await callPageBridge('acceptTrackedChanges', { deadlineAt, reviewOwnerToken: reviewOwners.get(runId)?.token,
485
547
  trackedChanges: run.undoTrackedChanges || [],
486
548
  expectedFiles: run.undoExpectedFiles || [],
@@ -488,7 +550,7 @@ async function executeAcceptRun(runId) {
488
550
  appliedOperations: Array.isArray(run.appliedOperations) ? run.appliedOperations : [],
489
551
  runProjectId: owner.projectId, accountScopeId: owner.accountScopeId
490
552
  });
491
- owner.assert();
553
+ run = assertInputs();
492
554
  appendAcceptDiagnosticEvents(runId, Array.isArray(result.diagnostics) ? result.diagnostics : []);
493
555
  appendRunRecordEvent(runId, {
494
556
  title: tr('runAcceptTrackedResult', { applied: result.applied?.length || 0, skipped: result.skipped?.length || 0 }),
@@ -565,10 +627,13 @@ async function applyLegacyUndoSettlement(runId, undoStatus, result) {
565
627
  })
566
628
  );
567
629
  await Modules.ScopedPersistenceCoordinator.applyReviewTransition({ getState, runId, transition,
568
- legacyUndo: true, persist: flushQueuedSaveState, assertCurrent });
630
+ legacyUndo: true, persist: flushQueuedSaveState, assertCurrent, SessionState: Modules.SessionState });
569
631
  refreshRunCardControls(runId);
570
632
  }
571
- return { recoverInterruptedUndoStatuses, undoRun, undoRunTrackedChanges, acceptRun, applyLegacyUndoSettlement };
633
+ // From the shared review lock until settlement, a replaced view would
634
+ // invalidate the action's ownership checks after Page effects.
635
+ const hasActiveReview = () => reviewOwners.size > 0;
636
+ return { recoverInterruptedUndoStatuses, undoRun, undoRunTrackedChanges, acceptRun, applyLegacyUndoSettlement, hasActiveReview };
572
637
  }
573
638
  return { create };
574
639
  });