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
@@ -107,7 +107,22 @@
107
107
  if (!copied) throw new Error('clipboard_copy_failed');
108
108
  }
109
109
 
110
- return Object.freeze({ configureResultActions });
110
+ return Object.freeze({ configureResultActions, projectUndoAvailability });
111
+ }
112
+
113
+ function projectUndoAvailability(run, projectRunSettlement) {
114
+ const payload = run.recoveryPayload && typeof run.recoveryPayload === 'object'
115
+ ? run.recoveryPayload : run;
116
+ if (!Array.isArray(payload.appliedOperations)) return projectRunSettlement(run);
117
+ // Forward binary writes do not provide a rollback. Filter them only for
118
+ // display; preserve the canonical settlement and every real recovery field.
119
+ const appliedOperations = payload.appliedOperations.filter(operation =>
120
+ operation?.type !== 'binary-create' && operation?.type !== 'overwrite-binary');
121
+ if (appliedOperations.length === payload.appliedOperations.length) return projectRunSettlement(run);
122
+ return projectRunSettlement({
123
+ ...run,
124
+ recoveryPayload: { ...payload, appliedOperations }
125
+ });
111
126
  }
112
127
 
113
128
  return Object.freeze({ create });
@@ -932,7 +932,7 @@
932
932
  return;
933
933
  }
934
934
 
935
- const projection = projectRunSettlement(run);
935
+ const projection = RunResultActions.projectUndoAvailability(run, projectRunSettlement);
936
936
  if (!projection.canUndo && run.undoStatus !== 'applied') {
937
937
  button.hidden = true;
938
938
  return;
@@ -960,12 +960,12 @@
960
960
  // same executable state as `pending`.
961
961
  function configureLifecycleUndoButton(button, run) {
962
962
  const status = run.trackedChangeStatus || '';
963
+ const inFlight = trackedChangeInFlight.get(run.id);
963
964
  const projection = projectRunSettlement(run);
964
965
  // §7 settlement matrix: needs_review keeps BOTH controls visible AND
965
966
  // actionable. Branch placed before the terminal branches so a
966
967
  // needs_review run is never treated as terminal.
967
968
  if (status === 'needs_review') {
968
- const inFlight = trackedChangeInFlight.get(run.id);
969
969
  button.hidden = false;
970
970
  button.disabled = !projection.canUndo || inFlight === 'reject' || inFlight === 'accept';
971
971
  button.textContent = tr('undoRun');
@@ -976,14 +976,14 @@
976
976
  });
977
977
  return;
978
978
  }
979
- if (status === 'rejected') {
979
+ if (status === 'rejected' && !inFlight) {
980
980
  button.hidden = false;
981
981
  button.disabled = true;
982
982
  button.textContent = tr('undoApplied');
983
983
  button.title = tr('undoAppliedTitle');
984
984
  return;
985
985
  }
986
- if (status === 'accepted') {
986
+ if (status === 'accepted' && !inFlight) {
987
987
  // The run was accepted — terminal. Undo stays visible but greyed so the
988
988
  // card shows both controls disabled, never removed.
989
989
  button.hidden = false;
@@ -992,11 +992,10 @@
992
992
  button.title = tr('undoRunTitle');
993
993
  return;
994
994
  }
995
- if (status !== 'pending') {
995
+ if (status !== 'pending' && !inFlight) {
996
996
  button.hidden = true;
997
997
  return;
998
998
  }
999
- const inFlight = trackedChangeInFlight.get(run.id);
1000
999
  button.hidden = false;
1001
1000
  button.disabled = !projection.canUndo || inFlight === 'reject' || inFlight === 'accept';
1002
1001
  button.textContent = tr('undoRun');
@@ -1034,12 +1033,12 @@
1034
1033
  }
1035
1034
 
1036
1035
  const status = run.trackedChangeStatus || '';
1036
+ const inFlight = trackedChangeInFlight.get(run.id);
1037
1037
  const projection = projectRunSettlement(run);
1038
1038
  // §7 settlement matrix: needs_review keeps BOTH controls visible AND
1039
1039
  // actionable. It remains an internal retryable proof state, while the
1040
1040
  // primary button label stays in the same executable state as `pending`.
1041
1041
  if (status === 'needs_review') {
1042
- const inFlight = trackedChangeInFlight.get(run.id);
1043
1042
  button.hidden = false;
1044
1043
  button.disabled = !projection.canAccept || inFlight === 'accept' || inFlight === 'reject';
1045
1044
  if (inFlight === 'accept') {
@@ -1052,14 +1051,14 @@
1052
1051
  wireAcceptInlineConfirm(button, run.id);
1053
1052
  return;
1054
1053
  }
1055
- if (status === 'accepted') {
1054
+ if (status === 'accepted' && !inFlight) {
1056
1055
  button.hidden = false;
1057
1056
  button.disabled = true;
1058
1057
  button.textContent = tr('runAcceptTrackedDone');
1059
1058
  button.title = tr('runAcceptTrackedDoneTitle');
1060
1059
  return;
1061
1060
  }
1062
- if (status === 'rejected') {
1061
+ if (status === 'rejected' && !inFlight) {
1063
1062
  // The run was rejected — terminal. Accept All stays visible but greyed so
1064
1063
  // the card shows both controls disabled, never removed.
1065
1064
  button.hidden = false;
@@ -1068,12 +1067,11 @@
1068
1067
  button.title = tr('runAcceptTrackedTitle');
1069
1068
  return;
1070
1069
  }
1071
- if (status !== 'pending') {
1070
+ if (status !== 'pending' && !inFlight) {
1072
1071
  button.hidden = true;
1073
1072
  return;
1074
1073
  }
1075
1074
 
1076
- const inFlight = trackedChangeInFlight.get(run.id);
1077
1075
  button.hidden = false;
1078
1076
  button.disabled = !projection.canAccept || inFlight === 'accept' || inFlight === 'reject';
1079
1077
  if (inFlight === 'accept') {
@@ -95,6 +95,7 @@
95
95
  }
96
96
  return Object.freeze({
97
97
  createController: createController,
98
- persistPanelState: PanelState?.persistPanelState || null
98
+ persistPanelState: PanelState?.persistPanelState || null,
99
+ applyReviewTransition: PanelState?.applyReviewTransition || null
99
100
  });
100
101
  });
@@ -15,6 +15,42 @@
15
15
  return JSON.parse(JSON.stringify(value));
16
16
  }
17
17
 
18
+ async function applyReviewTransition({ getState, runId, transition, persist }) {
19
+ const targets = () => Array.from(new Set([
20
+ ...(getState().runs || []),
21
+ ...(getState().sessions || []).flatMap(session => session.runs || [])
22
+ ])).filter(run => run.id === runId);
23
+ const records = targets();
24
+ const sessions = (getState().sessions || []).filter(session =>
25
+ (session.runs || []).some(run => run.id === runId));
26
+ if (!records.length || !sessions.length) throw reviewPersistenceError();
27
+ const previous = cloneSnapshot(records[0]);
28
+ for (const record of records) Object.assign(record, cloneSnapshot(transition));
29
+ for (const session of sessions) {
30
+ session.updatedAt = new Date(Math.max(Date.now(), (Date.parse(session.updatedAt) || 0) + 1,
31
+ (Date.parse(session.lastActivityAt) || 0) + 1)).toISOString();
32
+ session.lastActivityAt = session.updatedAt;
33
+ }
34
+ try {
35
+ await persist({ preserveRunActionPayload: true, reviewRunIds: [runId] });
36
+ } catch (error) {
37
+ // The remote action may have succeeded. Keep its recovery data and expose uncertainty.
38
+ for (const record of targets()) {
39
+ Object.assign(record, cloneSnapshot(previous), { trackedChangeStatus: 'needs_review' });
40
+ for (const field of ['settlement', 'settlementFacts']) {
41
+ if (!Object.prototype.hasOwnProperty.call(previous, field)) delete record[field];
42
+ }
43
+ }
44
+ throw error;
45
+ }
46
+ }
47
+
48
+ function reviewPersistenceError() {
49
+ return Object.assign(new Error('The review outcome was not durably saved. Check Overleaf before retrying.'), {
50
+ code: 'review_state_not_persisted'
51
+ });
52
+ }
53
+
18
54
  async function persistPanelState(input) {
19
55
  var state = cloneSnapshot(input.state || {});
20
56
  var compactState = cloneSnapshot(input.compactState || {});
@@ -35,6 +71,9 @@
35
71
  code: 'stale_view'
36
72
  });
37
73
  }
74
+ var reviewRuns = (input.reviewRunIds || []).map(id =>
75
+ (state.sessions || []).flatMap(session => session.runs || []).find(run => run.id === id));
76
+ if (reviewRuns.some(run => !run)) throw reviewPersistenceError();
38
77
  var latestPrefs = typeof Migration.loadPrefs === 'function'
39
78
  ? await Migration.loadPrefs(accountScopeId, projectId)
40
79
  : {};
@@ -94,18 +133,22 @@
94
133
  .filter(function (item) { return !queueTombstones[item?.id]; })
95
134
  }), { preserveRunActionPayload: true });
96
135
  });
97
- await input.SessionPersistence.writeSessions({
136
+ var written = await input.SessionPersistence.writeSessions({
98
137
  Migration: Migration,
99
138
  StorageDb: StorageDb,
100
139
  projectId: projectId,
101
140
  accountScopeId: accountScopeId,
102
141
  deletedSessionIds: deletedSessionIds,
103
142
  sessionRecords: sessionRecords,
143
+ reviewRunIds: input.reviewRunIds,
104
144
  queueClaims: persistenceMeta.queueClaims || {},
105
145
  queueTombstones: queueTombstones,
106
146
  writerId: input.persistenceContext?.writerId || ''
107
147
  });
148
+ if (!reviewRuns.every(reviewRun => (written || []).some(session => (session.runs || []).some(run =>
149
+ run.id === reviewRun.id && run.trackedChangeStatus === reviewRun.trackedChangeStatus
150
+ )))) throw reviewPersistenceError();
108
151
  }
109
152
 
110
- return Object.freeze({ persistPanelState: persistPanelState });
153
+ return Object.freeze({ persistPanelState: persistPanelState, applyReviewTransition: applyReviewTransition });
111
154
  });
@@ -16,19 +16,28 @@
16
16
  const source = findSource(previousState, runId);
17
17
  if (!source) return failFork('session_fork_source_missing', tr('forkRunUnavailable'));
18
18
  const { session, run, runIndex } = source;
19
- if (run.status === 'running' || !session.codexThreadId || !run.codexTurnId) {
19
+ const draftThreadId = run.codexTurnId && run.interruptedDraft?.turnId === run.codexTurnId
20
+ ? run.interruptedDraft.threadId : '';
21
+ // Provider switches replace the session thread, not the selected node's origin.
22
+ const sourceThreadId = String(run.codexThreadId || draftThreadId || session.codexThreadId || '').trim();
23
+ if (run.status === 'running' || !sourceThreadId || !run.codexTurnId) {
20
24
  return failFork('session_fork_unavailable', tr('forkRunUnavailable'));
21
25
  }
22
- const response = await sendBackgroundNative({
23
- method: 'codex.thread.fork',
24
- params: {
25
- threadId: session.codexThreadId,
26
- lastTurnId: run.codexTurnId,
27
- speedTier: session.speedTier,
28
- loadCodexLocalSkills: previousState.loadCodexLocalSkills !== false,
29
- loadCodexOverleafSkills: previousState.loadCodexOverleafSkills !== false
30
- }
31
- });
26
+ let response;
27
+ try {
28
+ response = await sendBackgroundNative({
29
+ method: 'codex.thread.fork',
30
+ params: {
31
+ threadId: sourceThreadId,
32
+ lastTurnId: run.codexTurnId,
33
+ speedTier: session.speedTier,
34
+ loadCodexLocalSkills: previousState.loadCodexLocalSkills !== false,
35
+ loadCodexOverleafSkills: previousState.loadCodexOverleafSkills !== false
36
+ }
37
+ });
38
+ } catch (error) {
39
+ return failFork(error?.code || 'session_fork_failed', error?.message || tr('forkRunFailed'));
40
+ }
32
41
  if (!response?.ok || !response.result?.threadId) {
33
42
  return failFork(response?.error?.code || 'session_fork_failed', response?.error?.message || tr('forkRunFailed'));
34
43
  }
@@ -73,7 +73,10 @@
73
73
  const existingById = new Map(existingSessions
74
74
  .filter(record => !deleted.has(record.id))
75
75
  .map(record => [record.id, record]));
76
- const writable = sessionRecords.filter(record => {
76
+ const mergedRecords = sessionRecords.map(record => StorageDb.mergeSessionReviewState?.(
77
+ record, existingById.get(record.id), options.reviewRunIds
78
+ ) || record);
79
+ const writable = mergedRecords.filter(record => {
77
80
  const existing = existingById.get(record.id);
78
81
  if (!existing) return true;
79
82
  return (Date.parse(record.updatedAt || '') || 0) >= (Date.parse(existing.updatedAt || '') || 0);
@@ -38,8 +38,8 @@
38
38
  <div class="codex-custom-instructions-head">
39
39
  <button type="button" data-settings-back title="Back" aria-label="Back">‹</button>
40
40
  <div>
41
- <div class="codex-custom-instructions-title" data-i18n="projectSettingsTitle">Project Settings</div>
42
- <div class="codex-custom-instructions-subtitle" data-i18n="projectSettingsSubtitle">Customize how Codex behaves in this and all projects.</div>
41
+ <div class="codex-custom-instructions-title" data-settings-title data-i18n="projectSettingsTitle">Project Settings</div>
42
+ <div class="codex-custom-instructions-subtitle" data-settings-subtitle data-i18n="projectSettingsSubtitle">Customize how Codex behaves in this and all projects.</div>
43
43
  </div>
44
44
  </div>
45
45
  <div class="codex-project-settings-status" data-project-settings-status></div>
@@ -304,6 +304,7 @@
304
304
  if (!root) {
305
305
  return;
306
306
  }
307
+ refreshSettingsScope(instance, root);
307
308
  const button = getButton(instance);
308
309
  if (button) {
309
310
  button.dataset.active = 'true';
@@ -312,6 +313,18 @@
312
313
  root.querySelector('[data-custom-instructions-input]')?.focus?.();
313
314
  }
314
315
 
316
+ function refreshSettingsScope(instance, root) {
317
+ const accountScope = instance.container?.closest?.('[data-settings-scope]')?.dataset?.settingsScope === 'account';
318
+ const prefix = accountScope ? 'recentProjects_settings' : 'projectSettings';
319
+ for (const [selector, suffix] of [['[data-settings-title]', 'Title'], ['[data-settings-subtitle]', 'Subtitle']]) {
320
+ const label = root.querySelector(selector);
321
+ if (!label) continue;
322
+ const key = prefix + suffix;
323
+ label.setAttribute('data-i18n', key);
324
+ label.textContent = t(instance, key);
325
+ }
326
+ }
327
+
315
328
  function hide(target) {
316
329
  const instance = target?._instance || target;
317
330
  const root = getRoot(instance);
@@ -346,6 +359,7 @@
346
359
  if (!root) {
347
360
  return;
348
361
  }
362
+ refreshSettingsScope(instance, root);
349
363
  if (Object.prototype.hasOwnProperty.call(state, 'customInstructions')) {
350
364
  const input = root.querySelector('[data-custom-instructions-input]');
351
365
  if (input) {
@@ -0,0 +1,153 @@
1
+ (function (root, factory) {
2
+ const api = factory();
3
+ if (typeof module === 'object' && module.exports) module.exports = api;
4
+ if (root) root.CodexOverleafTrackedChangeCaptureController = api;
5
+ })(typeof window !== 'undefined' ? window : globalThis, function () {
6
+ 'use strict';
7
+ function create(deps = {}) {
8
+ const now = deps.now || (() => Date.now());
9
+ const defer = deps.setTimeout || setTimeout;
10
+ const cancelTimer = deps.clearTimeout || clearTimeout;
11
+ let timer = null, busy = false, disposed = false;
12
+ const runs = () => deps.getRuns() || [];
13
+ const pending = run => Array.isArray(run.trackedChangeCaptures)
14
+ && run.trackedChangeCaptures.some(capture => capture.state === 'pending');
15
+ const terminal = run => ['accepted', 'rejected'].includes(run.trackedChangeStatus)
16
+ || run.undoStatus === 'applied' || run.forkSnapshot === true;
17
+ function buildPostFiles(run) {
18
+ const expectedFiles = Array.isArray(run?.undoExpectedFiles) ? run.undoExpectedFiles : [];
19
+ const appliedOperations = Array.isArray(run?.appliedOperations) ? run.appliedOperations : [];
20
+ if (!expectedFiles.length || !appliedOperations.length) return [];
21
+ const postFilesByPath = deps.buildExpectedFilesAfterOperations({ files: expectedFiles }, appliedOperations);
22
+ const expectedPaths = new Set(expectedFiles.map(file => file?.path).filter(Boolean));
23
+ return Array.from(postFilesByPath.entries())
24
+ .filter(([path, content]) => expectedPaths.has(path) && typeof content === 'string')
25
+ .map(([path, content]) => ({ path, content }));
26
+ }
27
+ function hasLegacyUntrackedCheckpoint(run) {
28
+ if (!run || run.executionSnapshot || run.trackedChangeStatus || run.undoStatus === 'applied'
29
+ || run.forkSnapshot || run.undoTrackedChanges?.length || run.trackedChangeCaptures?.length) return false;
30
+ const operations = Array.isArray(run.undoOperations) ? run.undoOperations : [];
31
+ const before = Array.isArray(run.undoExpectedFiles) ? run.undoExpectedFiles : [];
32
+ if (!operations.length || !before.length || before.some(file => !file?.path || typeof file.content !== 'string')) return false;
33
+ const paths = new Set(before.map(file => file.path));
34
+ if (paths.size !== before.length || operations.some(op => op?.type !== 'edit' || !paths.has(op.path))) return false;
35
+ const complete = files => Array.isArray(files) && files.length === paths.size
36
+ && new Set(files.map(file => file?.path)).size === paths.size
37
+ && files.every(file => paths.has(file?.path) && typeof file.content === 'string');
38
+ const storedPost = run.undoBaseFiles;
39
+ try {
40
+ const post = buildPostFiles(run);
41
+ if (!complete(storedPost) || !complete(post)) return false;
42
+ const postByPath = new Map(post.map(file => [file.path, file.content]));
43
+ if (!storedPost.every(file => postByPath.get(file.path) === file.content)) return false;
44
+ const restored = deps.buildExpectedFilesAfterOperations({ files: storedPost }, operations);
45
+ // A legacy checkpoint must prove both directions; empty refs alone grant nothing.
46
+ return before.every(file => restored.get(file.path) === file.content);
47
+ } catch (_error) { return false; }
48
+ }
49
+ function schedule() {
50
+ if (disposed || timer !== null || busy || !runs().some(pending)) return;
51
+ timer = defer(() => {
52
+ timer = null;
53
+ flush().catch(error => deps.onError?.(error));
54
+ }, 500);
55
+ }
56
+ function record(run, captures) {
57
+ if (!Array.isArray(captures) || !captures.length) return;
58
+ const next = [...(run.trackedChangeCaptures || []), ...captures.filter(Boolean)];
59
+ if (next.every(capture => capture.state === 'observed')) return;
60
+ run.trackedChangeCaptures = next;
61
+ deps.notify?.(run, next.some(capture => capture.state === 'pending') ? 'pending' : 'needs_review', {
62
+ captures: next.map(capture => ({ path: capture.path, reason: capture.reason, ...capture.diagnostics }))
63
+ });
64
+ deps.persist().then(schedule).catch(error => deps.onError?.(error));
65
+ }
66
+ async function flush() {
67
+ if (disposed || busy) return;
68
+ busy = true;
69
+ try {
70
+ for (const run of runs().filter(pending)) {
71
+ if (terminal(run)) { run.trackedChangeCaptures = []; await deps.persist(); continue; }
72
+ if (run.runProjectId !== deps.getProjectId()) continue;
73
+ const newer = runs().some(other => other.id !== run.id
74
+ && other.runProjectId === run.runProjectId
75
+ && Date.parse(other.startedAt) > Date.parse(run.startedAt));
76
+ const original = run.trackedChangeCaptures;
77
+ const captures = original.map(capture => ({ ...capture }));
78
+ const postFiles = buildPostFiles(run);
79
+ for (let i = 0; i < captures.length; i++) {
80
+ const capture = captures[i];
81
+ if (capture.state !== 'pending') continue;
82
+ const expected = postFiles.find(file => file.path === capture.path);
83
+ if (newer || !expected || now() >= capture.expiresAt || run.status === 'cancelled') {
84
+ capture.state = 'needs_review';
85
+ capture.reason = newer ? 'capture_superseded' : !expected ? 'capture_baseline_missing'
86
+ : run.status === 'cancelled' ? 'capture_cancelled' : 'capture_expired';
87
+ continue;
88
+ }
89
+ let response;
90
+ try {
91
+ response = await deps.callPageBridge('reconcileTrackedChangeCapture', {
92
+ runProjectId: run.runProjectId, capture, expectedContent: expected.content
93
+ });
94
+ } catch (_error) {
95
+ response = null;
96
+ }
97
+ // Never patch a superseded hydrated record or another project's state.
98
+ if (disposed || deps.getProjectId() !== run.runProjectId
99
+ || !runs().includes(run) || terminal(run) || run.trackedChangeCaptures !== original) return;
100
+ const superseded = runs().some(other => other.id !== run.id
101
+ && other.runProjectId === run.runProjectId
102
+ && Date.parse(other.startedAt) > Date.parse(run.startedAt));
103
+ if (superseded || run.status === 'cancelled') {
104
+ captures[i] = { ...capture, state: 'needs_review', reason: 'capture_superseded' };
105
+ } else if (response?.capture?.path === capture.path
106
+ && response.capture.runProjectId === run.runProjectId
107
+ && ['observed', 'pending', 'needs_review'].includes(response.capture.state)) {
108
+ captures[i] = response.capture;
109
+ } else {
110
+ captures[i] = { ...capture, state: 'needs_review', reason: 'capture_bridge_unavailable' };
111
+ }
112
+ }
113
+ if (!runs().includes(run) || terminal(run) || deps.getProjectId() !== run.runProjectId) continue;
114
+ if (captures.length && captures.every(capture => capture.state === 'observed')) {
115
+ const refs = deps.normalizeTrackedChanges([
116
+ ...(run.undoTrackedChanges || []), ...captures.flatMap(capture => capture.refs || [])
117
+ ]);
118
+ if (refs.length) {
119
+ run.undoTrackedChanges = refs;
120
+ run.trackedChangeStatus = 'pending';
121
+ run.trackedChangeCaptures = [];
122
+ await deps.persist();
123
+ deps.refresh(run);
124
+ deps.notify?.(run, 'observed', { count: refs.length });
125
+ continue;
126
+ }
127
+ }
128
+ const changed = JSON.stringify(original.map(summary)) !== JSON.stringify(captures.map(summary));
129
+ if (changed) {
130
+ run.trackedChangeCaptures = captures;
131
+ await deps.persist();
132
+ if (!pending(run)) deps.notify?.(run, 'needs_review', {
133
+ reasons: captures.filter(capture => capture.state !== 'observed').map(capture => capture.reason)
134
+ });
135
+ }
136
+ }
137
+ } finally {
138
+ busy = false;
139
+ if (runs().some(run => pending(run) && run.runProjectId === deps.getProjectId())) schedule();
140
+ }
141
+ }
142
+ function summary(capture) {
143
+ return { state: capture.state, reason: capture.reason, refs: (capture.refs || []).map(ref => ref.key) };
144
+ }
145
+ function dispose() {
146
+ disposed = true;
147
+ if (timer !== null) cancelTimer(timer);
148
+ timer = null;
149
+ }
150
+ return { record, schedule, flush, dispose, buildPostFiles, hasLegacyUntrackedCheckpoint };
151
+ }
152
+ return { create };
153
+ });
@@ -18,6 +18,7 @@
18
18
  let tx;
19
19
  let getLocale;
20
20
  let appendRunEvent;
21
+ let appendRunRecordEvent;
21
22
  // In-flight background mirror refresh (v1.7.5) — see the writeback path.
22
23
  let pendingMirrorRefresh = null;
23
24
  let appendChangeSummary;
@@ -69,6 +70,34 @@
69
70
  : null;
70
71
  }
71
72
 
73
+ function invalidateMirrorAfterUndo(runId, projectId, result = {}) {
74
+ if (!projectId || (result?.changedDocument !== true && !result?.applied?.length)) return Promise.resolve('not-attempted');
75
+ const pending = Promise.resolve(pendingMirrorRefresh).catch(() => {}).then(async () => {
76
+ const response = await sendBackgroundNative({ method: 'mirror.invalidate', params: { projectId } });
77
+ if (!response?.ok || response.result?.invalidated !== true) {
78
+ throw new Error(response?.error?.message || 'Native host did not confirm mirror invalidation.');
79
+ }
80
+ if (getCurrentProjectId() === projectId) {
81
+ resetContextProject();
82
+ await callPageBridge('invalidateProjectSnapshot', { invalidateFileList: true });
83
+ }
84
+ return 'invalidated';
85
+ }).catch(error => {
86
+ appendRunRecordEvent?.(runId, {
87
+ status: 'warning',
88
+ title: tx(
89
+ `Overleaf undo finished, but local mirror invalidation failed: ${error.message}. Refresh this project or reconnect the native host before running again.`,
90
+ `Overleaf 撤销已完成,但本地镜像失效处理失败:${error.message}。请刷新项目或重新连接 Native Host 后再运行。`
91
+ )
92
+ });
93
+ return 'failed';
94
+ }).finally(() => {
95
+ if (pendingMirrorRefresh === pending) pendingMirrorRefresh = null;
96
+ });
97
+ pendingMirrorRefresh = pending;
98
+ return pending;
99
+ }
100
+
72
101
  async function applySyncChangesToOverleaf(syncChanges = [], project = {}, options = {}) {
73
102
  const runMode = options.mode || getState().mode;
74
103
  const runRequireReviewing = typeof options.requireReviewing === 'boolean'
@@ -822,6 +851,7 @@
822
851
  tx,
823
852
  getLocale,
824
853
  appendRunEvent,
854
+ appendRunRecordEvent,
825
855
  appendChangeSummary,
826
856
  appendCompletionReport,
827
857
  appendOperationsPreview,
@@ -867,6 +897,7 @@
867
897
  return {
868
898
  applySyncChangesToOverleaf,
869
899
  resolveCompileLogContext,
900
+ invalidateMirrorAfterUndo,
870
901
  getPendingMirrorRefresh: () => pendingMirrorRefresh
871
902
  };
872
903
  }