codex-overleaf-link 2.3.6 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +59 -34
  2. package/README.zh-CN.md +543 -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 +53 -50
  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 +583 -112
  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/runResultActions.js +16 -1
  21. package/extension/src/content/runTimelineView.js +1 -1
  22. package/extension/src/content/sessionForkController.js +20 -11
  23. package/extension/src/content/trackedChangeCaptureController.js +153 -0
  24. package/extension/src/content/writebackOrchestrator.js +31 -0
  25. package/extension/src/page/binaryAssetUploader.js +101 -29
  26. package/extension/src/page/overleafRealtimeObserver.js +4 -1
  27. package/extension/src/page/textFileCreator.js +493 -0
  28. package/extension/src/page/trackedChangeCapture.js +342 -0
  29. package/extension/src/page/trackedChangesLifecycle.js +145 -149
  30. package/extension/src/page/treeOperations.js +30 -2
  31. package/extension/src/page/writebackRouter.js +94 -48
  32. package/extension/src/pageBridge.js +62 -25
  33. package/extension/src/shared/compatibility.js +1 -1
  34. package/extension/src/shared/pageRpcContract.js +2 -1
  35. package/extension/src/shared/sessionState.js +6 -2
  36. package/extension/src/shared/storageDb.js +10 -12
  37. package/extension/src/shared/storageRunActions.js +29 -1
  38. package/extension/src/shared/writebackSettlement.js +19 -5
  39. package/extension/styles/panel.css +55 -10
  40. package/native-host/src/taskRunnerRuntime.js +25 -0
  41. package/package.json +5 -1
@@ -2,9 +2,9 @@
2
2
  "schemaVersion": 1,
3
3
  "entry": "extension/entries/content-entry.mjs",
4
4
  "output": "extension/src/content/generated/content.bundle.js",
5
- "sourceDigest": "15c3e103532c84dea57aeac33e02ec665fbf7518543e14daac2459c434b3e5d5",
6
- "outputDigest": "5e1dba4bfcf4c595835312deabefa5ecdd2ee7c6d98f29d1b2bd9711ad5b195c",
7
- "byteLength": 2375784,
5
+ "sourceDigest": "f146d7e07b67706193fa5fcff26d7c9719826ac745aec9b745dd95c52413d27c",
6
+ "outputDigest": "11993fd0746a026b1e74399060e70559eb569fca492fcdec36c2fb69a78d6b80",
7
+ "byteLength": 2404976,
8
8
  "inputs": [
9
9
  "extension/entries/content-entry.mjs",
10
10
  "extension/src/content/activeTurnControl.js",
@@ -62,6 +62,7 @@
62
62
  "extension/src/content/sessionPersistence.js",
63
63
  "extension/src/content/settingsPanel.js",
64
64
  "extension/src/content/themeController.js",
65
+ "extension/src/content/trackedChangeCaptureController.js",
65
66
  "extension/src/content/updateIdle.js",
66
67
  "extension/src/content/updateNotice.js",
67
68
  "extension/src/content/writebackController.js",
@@ -75,6 +75,7 @@
75
75
  'StorageMigration',
76
76
  'Summary',
77
77
  'Theme',
78
+ 'TrackedChangeCaptureController',
78
79
  'UndoOperations',
79
80
  'UpdateIdle',
80
81
  'UpdateNotice',
@@ -134,7 +134,8 @@
134
134
  return { stale: false, error: response?.error || { code: 'provider_models_unavailable' } };
135
135
  }
136
136
  const sourceModels = hasDiscoveredModels ? response.result.models : fallbackModels;
137
- const retainedSelectedModel = retainSelectedModel(sourceModels, currentSelectedModel);
137
+ const preserveBuiltin = (selection?.providerId || 'builtin') === 'builtin' && (getState()?.providerId || 'builtin') === 'builtin';
138
+ const retainedSelectedModel = retainSelectedModel(sourceModels, currentSelectedModel, { preserveUnavailable: preserveBuiltin });
138
139
  const normalized = modelCatalog.normalizeDiscoveredModels({ models: sourceModels, selectedModel: retainedSelectedModel });
139
140
  if (normalized.usedFallback && response.result?.providerId && response.result.providerId !== 'builtin') {
140
141
  applyUnavailableModelOptions({ code: 'provider_model_catalog_invalid', message: 'The active provider returned no usable models.' });
@@ -143,9 +144,12 @@
143
144
  error: { code: 'provider_model_catalog_invalid', message: 'The active provider returned no usable models.' }
144
145
  };
145
146
  }
146
- renderModelOptions(normalized.models, retainedSelectedModel, { allowUnknownSelected: false });
147
+ const renderedModels = preserveBuiltin
148
+ ? Support.preserveSelectedCapabilities(normalized.models, retainedSelectedModel, getState(), !hasDiscoveredModels || response.result?.source === 'fallback')
149
+ : normalized.models;
150
+ renderModelOptions(renderedModels, retainedSelectedModel, { allowUnknownSelected: false });
147
151
  modelDiscovery = {
148
- status: hasDiscoveredModels && !normalized.usedFallback ? 'discovered' : 'fallback',
152
+ status: hasDiscoveredModels && !normalized.usedFallback && response.result?.source !== 'fallback' ? 'discovered' : 'fallback',
149
153
  source: hasDiscoveredModels ? response.result?.source || 'unknown' : 'fallback',
150
154
  fetchedAt: hasDiscoveredModels ? response.result?.fetchedAt || '' : '',
151
155
  errorCode: hasDiscoveredModels ? '' : response?.error?.code || '',
@@ -172,15 +176,16 @@
172
176
  }
173
177
 
174
178
  function applyUnavailableModelOptions(error) {
179
+ const selectedModel = resolveSelectedModel();
175
180
  const modelSelect = getPanel()?.querySelector('[data-model]');
176
181
  if (modelSelect) {
177
182
  modelSelect.textContent = '';
178
183
  const option = document.createElement('option');
179
- option.value = resolveSelectedModel();
184
+ option.value = selectedModel;
180
185
  option.textContent = tx('Models unavailable', '模型不可用');
181
186
  option.disabled = true;
182
187
  modelSelect.append(option);
183
- modelSelect.value = '';
188
+ modelSelect.value = selectedModel;
184
189
  modelSelect.disabled = true;
185
190
  }
186
191
  modelDiscovery = {
@@ -190,8 +195,15 @@
190
195
  errorCode: error?.code || '',
191
196
  errorMessage: error?.message || (error ? String(error) : '')
192
197
  };
193
- renderReasoningOptions([]);
194
- renderSpeedOptions([]);
198
+ const pendingModels = Support.preserveSelectedCapabilities(
199
+ [{ id: selectedModel, unverified: true }], selectedModel, getState(), true
200
+ );
201
+ renderReasoningOptions(pendingModels);
202
+ renderSpeedOptions(pendingModels);
203
+ for (const selector of ['[data-reasoning]', '[data-speed]']) {
204
+ const input = getPanel()?.querySelector(selector);
205
+ if (input) input.disabled = true;
206
+ }
195
207
  renderModelConfigChoices();
196
208
  updateModelDisplay();
197
209
  }
@@ -200,9 +212,9 @@
200
212
  const modelCatalog = getModelCatalog();
201
213
  const fallbackModels = modelCatalog.FALLBACK_MODELS;
202
214
  const sourceModels = fallbackModels;
203
- const retainedSelectedModel = retainSelectedModel(sourceModels, selectedModel);
215
+ const retainedSelectedModel = retainSelectedModel(sourceModels, selectedModel, { preserveUnavailable: (getState()?.providerId || 'builtin') === 'builtin' });
204
216
  const normalized = modelCatalog.normalizeDiscoveredModels({ models: sourceModels, selectedModel: retainedSelectedModel });
205
- renderModelOptions(normalized.models, retainedSelectedModel);
217
+ renderModelOptions(Support.preserveSelectedCapabilities(normalized.models, retainedSelectedModel, getState(), true), retainedSelectedModel);
206
218
  modelDiscovery = {
207
219
  status: 'fallback',
208
220
  source: 'fallback',
@@ -29,14 +29,49 @@
29
29
  return String(label || '').replace(/^gpt[-\s]*/i, '');
30
30
  }
31
31
 
32
- function retainSelectedModel(models, selectedModel) {
32
+ function retainSelectedModel(models, selectedModel, options = {}) {
33
33
  const selectedId = normalizeModelOptionId(selectedModel);
34
34
  if (!selectedId) return '';
35
+ if (options.preserveUnavailable === true) return selectedId;
35
36
  return (Array.isArray(models) ? models : []).some(model => (
36
37
  normalizeModelOptionId(typeof model === 'string' ? model : model?.id) === selectedId
37
38
  )) ? selectedId : '';
38
39
  }
39
40
 
41
+ // A fallback or missing catalog entry cannot revoke an explicit selection.
42
+ // Keep its saved controls provisional until real capability data arrives.
43
+ function preserveSelectedCapabilities(models, selectedModel, preferences = {}, provisional = false) {
44
+ const selectedId = normalizeModelOptionId(selectedModel);
45
+ const saved = preferences || {};
46
+ return (Array.isArray(models) ? models : []).map(model => {
47
+ if (model.id !== selectedId || (!provisional && !model.unverified)) return model;
48
+ const reasoningEfforts = normalizeReasoningEffortsForSelect([
49
+ ...(model.reasoningEfforts || []), saved.reasoningEffort
50
+ ]);
51
+ const speedTiers = normalizeSpeedTiersForSelect(model.speedTiers);
52
+ const speed = ['standard', 'fast'].includes(saved.speedTier) ? saved.speedTier : '';
53
+ if (speed && !speedTiers.includes(speed)) speedTiers.push(speed);
54
+ return {
55
+ ...model, unverified: true, reasoningEfforts, speedTiers,
56
+ defaultReasoningEffort: reasoningEfforts.includes(saved.reasoningEffort)
57
+ ? saved.reasoningEffort : model.defaultReasoningEffort,
58
+ defaultSpeedTier: speed || model.defaultSpeedTier || 'standard'
59
+ };
60
+ });
61
+ }
62
+
63
+ function getRenderedModelEntries(panel) {
64
+ return Array.from(panel?.querySelector('[data-model]')?.options || []).map(option => ({
65
+ id: option.value,
66
+ label: option.textContent,
67
+ reasoningEfforts: (option.dataset.reasoningEfforts || '').split(',').filter(Boolean),
68
+ defaultReasoningEffort: option.dataset.defaultReasoningEffort || '',
69
+ reasoningPresentation: option.dataset.reasoningPresentation || '',
70
+ speedTiers: (option.dataset.speedTiers || 'standard').split(',').filter(Boolean),
71
+ defaultSpeedTier: option.dataset.defaultSpeedTier || 'standard'
72
+ }));
73
+ }
74
+
40
75
  function shouldUseBuiltInFallback(response) {
41
76
  const providerId = normalizeModelOptionId(response?.result?.providerId);
42
77
  return response?.ok === true && (!providerId || providerId === 'builtin');
@@ -114,6 +149,8 @@
114
149
  normalizeReasoningEffortsForSelect,
115
150
  normalizeSpeedTiersForSelect,
116
151
  retainSelectedModel,
152
+ preserveSelectedCapabilities,
153
+ getRenderedModelEntries,
117
154
  shouldUseBuiltInFallback
118
155
  };
119
156
  return api;
@@ -23,7 +23,10 @@
23
23
  async function dispatch(payload, channelMethod) {
24
24
  const compatibilityGate = await ensureForMethod(payload?.method);
25
25
  if (!compatibilityGate.ok) return compatibilityGate.response;
26
- if (gatedMethods.has(payload?.method)) options.throwIfCancellationRequested?.();
26
+ // Post-Undo cache bookkeeping must survive the ended run's cancellation.
27
+ if (gatedMethods.has(payload?.method) && payload.method !== 'mirror.invalidate') {
28
+ options.throwIfCancellationRequested?.();
29
+ }
27
30
  return nativeChannel[channelMethod](
28
31
  attachEvidence(payload, compatibilityGate.compatibility)
29
32
  );
@@ -1,6 +1,5 @@
1
1
  (function initCodexOverleafOtWarmMirror() {
2
2
  'use strict';
3
-
4
3
  // Experimental OT warm-mirror glue carved out of contentRuntime.js (v1.4.9
5
4
  // structural-debt phase 5): the per-project enable toggle flow, the
6
5
  // poll/flush timers and patch queue, mirror prefetch, warm-start
@@ -52,6 +51,7 @@
52
51
  let currentOtStatus = 'off';
53
52
  let otTogglePending = false;
54
53
  let otSyncRequestId = 0;
54
+ let otStartRetryCount = 0;
55
55
  let otWarmMirrorProjectId = '';
56
56
  let lastExperimentalOtProjectId = '';
57
57
  let otWarmMirrorState = {
@@ -226,6 +226,7 @@
226
226
  const projectId = getCurrentProjectId();
227
227
  otWarmMirrorState.failureActive = false;
228
228
  lastExperimentalOtProjectId = projectId;
229
+ otStartRetryCount = 0;
229
230
  setExperimentalOtEnabledForProject(projectId, checkbox.checked);
230
231
  updateExperimentalOtToggleControl(checkbox.checked);
231
232
  // Per-card saved feedback, same contract as every other settings card.
@@ -306,8 +307,10 @@
306
307
  await pauseOtWarmMirror(pauseAfterStart.reason || 'paused');
307
308
  return response;
308
309
  }
309
- otWarmMirrorState.failureActive = false;
310
+ if (status === 'starting') recordOtFailure('missing_active_path', 'start');
311
+ else otWarmMirrorState.failureActive = false;
310
312
  updateOtStatusDisplay(status);
313
+ otStartRetryCount = 0;
311
314
  scheduleOtEventPolling(projectId, { immediate: true });
312
315
  return response;
313
316
  }
@@ -338,21 +341,36 @@
338
341
  clearOtEventPolling({ clearPatchQueue: true });
339
342
  recordOtFailure(readOtBridgeErrorCode(response, 'ot_start_failed'), 'start');
340
343
  otWarmMirrorState.suspended = true;
341
- setExperimentalOtEnabledForProject(projectId, false);
342
- const experimentalOtCheckbox = getPanel()?.querySelector('[data-experimental-ot]');
343
- if (experimentalOtCheckbox) {
344
- experimentalOtCheckbox.checked = false;
345
- }
344
+ // Observer availability is transient; only an explicit user action may
345
+ // change the persisted opt-in. A cold editor/bridge must not turn OT off
346
+ // permanently. failureActive/suspended already prohibit cache reuse.
347
+ updateExperimentalOtToggleControl(isExperimentalOtEnabledForProject(projectId));
346
348
  updateOtStatusDisplay('unavailable');
347
- saveStateSoon();
348
349
  try { await callPageBridge('stopOtObserver', { projectId }); } catch (_error) { /* preserve the original failure */ }
350
+ scheduleOtStartRetry(projectId, requestId);
351
+ }
352
+
353
+ function scheduleOtStartRetry(projectId, requestId) {
354
+ const retryDelays = [1000, 3000, 6000];
355
+ const canRetry = () => isCurrentOtSync(requestId, projectId) && getCurrentProjectId() === projectId
356
+ && isExperimentalOtEnabledForProject(projectId) && !getCurrentRunView();
357
+ if (!canRetry() || otStartRetryCount >= retryDelays.length) return;
358
+ // Use the existing owned timer so disabling OT, changing projects and
359
+ // starting a new observation all cancel pending retries normally.
360
+ const delayMs = retryDelays[otStartRetryCount++];
361
+ otWarmMirrorState.pollTimer = window.setTimeout(() => {
362
+ otWarmMirrorState.pollTimer = null;
363
+ if (!canRetry()) return;
364
+ syncOtWarmMirrorController().catch(() => {
365
+ if (getCurrentProjectId() === projectId && isExperimentalOtEnabledForProject(projectId)) {
366
+ updateOtStatusDisplay('unavailable');
367
+ }
368
+ });
369
+ }, delayMs);
349
370
  }
350
371
 
351
372
  function readOtBridgeStatus(response) {
352
- if (!response || response.ok === false) {
353
- return 'unavailable';
354
- }
355
- return response.state || response.status || response.result?.state || response.result?.status || 'unavailable';
373
+ return otWarmMirrorController.readOtBridgeStatus(response);
356
374
  }
357
375
 
358
376
  function ensureOtWarmMirrorStateProject(projectId) {
@@ -361,6 +379,7 @@
361
379
  }
362
380
  clearOtEventPolling({ clearPatchQueue: true });
363
381
  otWarmMirrorState.projectId = projectId;
382
+ otStartRetryCount = 0;
364
383
  otWarmMirrorState.lastPatchAt = 0;
365
384
  otWarmMirrorState.lastErrorCode = '';
366
385
  otWarmMirrorState.failureActive = false;
@@ -438,6 +457,7 @@
438
457
  return statusResponse;
439
458
  }
440
459
  const status = readOtBridgeStatus(statusResponse);
460
+ if (status === 'observing' && otWarmMirrorState.lastFailure?.phase === 'start') Object.assign(otWarmMirrorState, { failureActive: false, lastErrorCode: '', lastFailure: null });
441
461
  if (['unavailable', 'inconsistent'].includes(status)) recordOtFailure(readOtBridgeErrorCode(statusResponse, 'ot_observer_unavailable'), 'observe');
442
462
  if (status) {
443
463
  updateOtStatusDisplay(status);
@@ -470,27 +490,11 @@
470
490
  }
471
491
 
472
492
  function readOtBridgeEvents(response) {
473
- if (Array.isArray(response)) {
474
- return response;
475
- }
476
- if (Array.isArray(response?.events)) {
477
- return response.events;
478
- }
479
- if (Array.isArray(response?.result?.events)) {
480
- return response.result.events;
481
- }
482
- return [];
493
+ return otWarmMirrorController.readOtBridgeEvents(response);
483
494
  }
484
-
485
495
  function readOtBridgeErrorCode(response, fallback) {
486
- const value = response?.lastErrorCode
487
- || response?.reason
488
- || response?.error?.code
489
- || response?.error
490
- || fallback;
491
- return typeof value === 'string' ? value : fallback;
496
+ return otWarmMirrorController.readOtBridgeErrorCode(response, fallback);
492
497
  }
493
-
494
498
  function recordOtFailure(code, phase) {
495
499
  code = otWarmMirrorController.normalizeFailureCode(code);
496
500
  otWarmMirrorState.lastErrorCode = code;
@@ -174,6 +174,23 @@
174
174
  return { ok: true };
175
175
  }
176
176
 
177
+ function readOtBridgeStatus(response) {
178
+ if (!response || response.ok === false) return 'unavailable';
179
+ return response.state || response.status || response.result?.state || response.result?.status || 'unavailable';
180
+ }
181
+
182
+ function readOtBridgeEvents(response) {
183
+ if (Array.isArray(response)) return response;
184
+ if (Array.isArray(response?.events)) return response.events;
185
+ if (Array.isArray(response?.result?.events)) return response.result.events;
186
+ return [];
187
+ }
188
+
189
+ function readOtBridgeErrorCode(response, fallback) {
190
+ const value = response?.lastErrorCode || response?.reason || response?.error?.code || response?.error || fallback;
191
+ return typeof value === 'string' ? value : fallback;
192
+ }
193
+
177
194
  function normalizeFailureCode(value, fallback = 'ot_error') {
178
195
  const code = typeof value === 'string' ? value : value?.code;
179
196
  if (/^[a-z][a-z0-9_]{0,79}$/.test(code || '')) return code;
@@ -265,6 +282,9 @@
265
282
  takePatchBatch,
266
283
  validatePatchReceipt,
267
284
  normalizeFailureCode,
285
+ readOtBridgeStatus,
286
+ readOtBridgeEvents,
287
+ readOtBridgeErrorCode,
268
288
  getFailureMessageKey,
269
289
  buildPatchFilesRequest,
270
290
  canUseOtWarmStart,
@@ -21,9 +21,11 @@
21
21
  ['src/page/overleafProjectSnapshot.js', 'codex-overleaf-project-snapshot-script'],
22
22
  ['src/page/pageBridgeCapability.js', 'codex-overleaf-page-bridge-capability-script'],
23
23
  ['src/page/treeOperations.js', 'codex-overleaf-tree-operations-script', true],
24
+ ['src/page/textFileCreator.js', 'codex-overleaf-text-file-creator-script', true],
24
25
  ['src/page/binaryAssetUploader.js', 'codex-overleaf-binary-asset-uploader-script', true],
25
26
  ['src/page/snapshotRouter.js', 'codex-overleaf-snapshot-router-script'],
26
27
  ['src/page/writeGuard.js', 'codex-overleaf-write-guard-script', true],
28
+ ['src/page/trackedChangeCapture.js', 'codex-overleaf-tracked-change-capture-script', true],
27
29
  ['src/page/trackedChangesLifecycle.js', 'codex-overleaf-tracked-changes-lifecycle-script', true],
28
30
  ['src/page/writebackRouter.js', 'codex-overleaf-writeback-router-script', true],
29
31
  ['src/pageBridge.js', 'codex-overleaf-page-bridge-script', true]
@@ -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;
@@ -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
  }
@@ -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
  }