codex-overleaf-link 2.3.4 → 2.3.6

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 +325 -211
  2. package/extension/bootstrap/manifest.template.json +1 -1
  3. package/extension/src/background.js +9 -0
  4. package/extension/src/backgroundUpdateCoordinator.js +88 -4
  5. package/extension/src/content/composerPanel.js +1 -1
  6. package/extension/src/content/contentRuntime.js +51 -35
  7. package/extension/src/content/diagnosticsController.js +13 -3
  8. package/extension/src/content/diagnosticsPanel.js +1 -1
  9. package/extension/src/content/generated/content.bundle.js +1233 -298
  10. package/extension/src/content/generated/content.bundle.meta.json +8 -3
  11. package/extension/src/content/globalPreferencesController.js +125 -0
  12. package/extension/src/content/legacyGlobalRegistry.js +4 -0
  13. package/extension/src/content/otWarmMirror.js +95 -65
  14. package/extension/src/content/otWarmMirrorController.js +102 -1
  15. package/extension/src/content/panelMaintenance.js +26 -15
  16. package/extension/src/content/panelRenderer.js +6 -1
  17. package/extension/src/content/projectSettingsCoordinator.js +1 -0
  18. package/extension/src/content/providerSettingsCoordinator.js +16 -0
  19. package/extension/src/content/providerSettingsDialog.js +24 -15
  20. package/extension/src/content/runFailureNotice.js +104 -0
  21. package/extension/src/content/runScrollLayout.js +124 -0
  22. package/extension/src/content/runTimelineView.js +36 -10
  23. package/extension/src/content/settingsPanel.js +32 -13
  24. package/extension/src/content/themeController.js +3 -0
  25. package/extension/src/content/updateNotice.js +24 -4
  26. package/extension/src/page/overleafRealtimeObserver.js +116 -24
  27. package/extension/src/shared/compatibility.js +1 -1
  28. package/extension/src/shared/globalPreferences.js +131 -0
  29. package/extension/src/shared/i18n.js +79 -39
  30. package/extension/src/shared/managedUpdateProjection.js +1 -1
  31. package/extension/src/shared/otText.js +0 -1
  32. package/extension/src/shared/sessionState.js +17 -21
  33. package/extension/src/shared/sessionTitle.js +120 -0
  34. package/extension/src/shared/updateRuntimeIdentity.js +32 -0
  35. package/extension/styles/panel.css +90 -16
  36. package/native-host/src/mirrorWorkspace.js +20 -1
  37. package/native-host/src/releaseProxy.js +177 -0
  38. package/native-host/src/releaseTransport.js +154 -0
  39. package/native-host/src/updateManager.js +40 -39
  40. package/package.json +1 -1
  41. package/scripts/install-managed.mjs +1 -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": "48e7ba193b0723f8e135c4b52e3af1416b1fe35b3cb780207fb0600e7e0f8019",
6
- "outputDigest": "866811d95e8bd58d369e0790bf2af5becc93946eb8ad987b80f0be0f80fa965f",
7
- "byteLength": 2321914,
5
+ "sourceDigest": "15c3e103532c84dea57aeac33e02ec665fbf7518543e14daac2459c434b3e5d5",
6
+ "outputDigest": "5e1dba4bfcf4c595835312deabefa5ecdd2ee7c6d98f29d1b2bd9711ad5b195c",
7
+ "byteLength": 2375784,
8
8
  "inputs": [
9
9
  "extension/entries/content-entry.mjs",
10
10
  "extension/src/content/activeTurnControl.js",
@@ -17,6 +17,7 @@
17
17
  "extension/src/content/diagnosticsController.js",
18
18
  "extension/src/content/diagnosticsPanel.js",
19
19
  "extension/src/content/diffReviewPanel.js",
20
+ "extension/src/content/globalPreferencesController.js",
20
21
  "extension/src/content/legacyGlobalRegistry.js",
21
22
  "extension/src/content/localSkillsPanel.js",
22
23
  "extension/src/content/markdownDomRenderer.js",
@@ -43,10 +44,12 @@
43
44
  "extension/src/content/recentProjects.js",
44
45
  "extension/src/content/reviewHunks.js",
45
46
  "extension/src/content/runController.js",
47
+ "extension/src/content/runFailureNotice.js",
46
48
  "extension/src/content/runGuidanceController.js",
47
49
  "extension/src/content/runGuidanceView.js",
48
50
  "extension/src/content/runQueueScheduler.js",
49
51
  "extension/src/content/runResultActions.js",
52
+ "extension/src/content/runScrollLayout.js",
50
53
  "extension/src/content/runSettlementPersistence.js",
51
54
  "extension/src/content/runTimelineView.js",
52
55
  "extension/src/content/scopedPersistenceCoordinator.js",
@@ -68,6 +71,7 @@
68
71
  "extension/src/shared/compatibility.js",
69
72
  "extension/src/shared/compileAdapter.js",
70
73
  "extension/src/shared/failureReasons.js",
74
+ "extension/src/shared/globalPreferences.js",
71
75
  "extension/src/shared/governanceRules.js",
72
76
  "extension/src/shared/i18n.js",
73
77
  "extension/src/shared/lineReferences.js",
@@ -88,6 +92,7 @@
88
92
  "extension/src/shared/scopedPersistenceTransaction.js",
89
93
  "extension/src/shared/sensitiveScan.js",
90
94
  "extension/src/shared/sessionState.js",
95
+ "extension/src/shared/sessionTitle.js",
91
96
  "extension/src/shared/settlementFacts.js",
92
97
  "extension/src/shared/staleGuard.js",
93
98
  "extension/src/shared/storageDb.js",
@@ -0,0 +1,125 @@
1
+ (function (root, factory) {
2
+ if (typeof module === 'object' && module.exports) module.exports = factory(require('../shared/globalPreferences'));
3
+ else root.CodexOverleafModuleRegistry.define('GlobalPreferencesController', ['GlobalPreferences'], factory);
4
+ })(typeof globalThis !== 'undefined' ? globalThis : this, function (Preferences) {
5
+ 'use strict';
6
+
7
+ const CONTROLS = Object.freeze({ theme: '[data-theme-select]', locale: '[data-language-select]',
8
+ preloadProjectContext: '[data-preload-project-context]', loadCodexLocalSkills: '[data-load-codex-local-skills]',
9
+ loadCodexOverleafSkills: '[data-load-codex-overleaf-skills]' });
10
+
11
+ function create(options) {
12
+ const chromeApi = options.chromeApi;
13
+ const fallback = Preferences.pick(options.getState?.() || {});
14
+ let snapshot = null;
15
+ let loading = null;
16
+ let subscribed = false;
17
+ let disposed = false;
18
+ let lastError = null;
19
+ let tail = Promise.resolve();
20
+ const pending = [];
21
+
22
+ function view() {
23
+ return pending.reduce((values, operation) => Preferences.applyPatch(values, operation.patch), snapshot?.values || fallback);
24
+ }
25
+
26
+ function render(force = false) {
27
+ if (disposed) return;
28
+ const previous = options.getState?.() || {};
29
+ const values = view();
30
+ const changed = Preferences.changedKeys(Preferences.pick(previous), values);
31
+ options.setState?.({ ...previous, ...values });
32
+ const panel = options.getPanel?.();
33
+ if (!panel) return;
34
+ for (const [field, selector] of Object.entries(CONTROLS)) {
35
+ const control = panel.querySelector(selector);
36
+ if (!control) continue;
37
+ if (typeof values[field] === 'boolean') control.checked = values[field];
38
+ else if (control.value !== values[field]) control.value = values[field];
39
+ }
40
+ if (force || changed.includes('theme')) options.applyTheme?.(values.theme);
41
+ if (changed.includes('locale')) options.applyLocale?.();
42
+ if (force || changed.includes('preloadProjectContext')) options.onPreloadChange?.(values.preloadProjectContext);
43
+ if (changed.some(key => key === 'codexOverleafSkillEnabled' || key.startsWith('loadCodex'))) options.onSkillsChange?.();
44
+ }
45
+
46
+ function accept(value) {
47
+ const next = Preferences.validateSnapshot(value);
48
+ lastError = null;
49
+ if (!snapshot || next.revision > snapshot.revision) {
50
+ snapshot = next;
51
+ render();
52
+ }
53
+ return snapshot;
54
+ }
55
+
56
+ async function request(type, patch) {
57
+ const response = await chromeApi.runtime.sendMessage({ type, ...(patch ? { patch } : {}) });
58
+ if (!response?.ok) throw new Error(response?.error?.message || 'Global preferences are unavailable.');
59
+ return response.result;
60
+ }
61
+
62
+ function onStorageChange(changes, area) {
63
+ if (disposed || area !== 'local' || !changes[Preferences.STORAGE_KEY]?.newValue) return;
64
+ try { accept(changes[Preferences.STORAGE_KEY].newValue); } catch (error) { reportError(error); }
65
+ }
66
+
67
+ function reportError(error) {
68
+ lastError = error;
69
+ options.onError?.(error);
70
+ }
71
+
72
+ async function initialize() {
73
+ if (!subscribed) {
74
+ chromeApi.storage.onChanged.addListener(onStorageChange);
75
+ subscribed = true;
76
+ }
77
+ if (snapshot) return snapshot;
78
+ if (!loading) {
79
+ loading = request(Preferences.READ_MESSAGE).then(accept).catch(error => {
80
+ reportError(error);
81
+ return null;
82
+ }).finally(() => { loading = null; });
83
+ }
84
+ return loading;
85
+ }
86
+
87
+ function update(input) {
88
+ const patch = Preferences.validatePatch(input);
89
+ if (!Preferences.changedKeys(view(), Preferences.applyPatch(view(), patch)).length) return pending.length ? tail : Promise.resolve(snapshot);
90
+ const operation = { patch };
91
+ pending.push(operation);
92
+ render();
93
+ const result = tail.catch(() => {}).then(() => request(Preferences.PATCH_MESSAGE, patch)).then(Preferences.validateSnapshot).then(value => {
94
+ accept(value);
95
+ pending.splice(pending.indexOf(operation), 1);
96
+ render();
97
+ return snapshot;
98
+ }, error => {
99
+ pending.splice(pending.indexOf(operation), 1);
100
+ render();
101
+ reportError(error);
102
+ throw error;
103
+ });
104
+ tail = result;
105
+ return result;
106
+ }
107
+
108
+ function handleInput(event) {
109
+ const field = Object.keys(CONTROLS).find(key => event?.target?.matches?.(CONTROLS[key]));
110
+ if (!field) return null;
111
+ return update({ [field]: ['theme', 'locale'].includes(field) ? event.target.value : Boolean(event.target.checked) });
112
+ }
113
+
114
+ return Object.freeze({ initialize, update, handleInput,
115
+ overlay: state => snapshot || pending.length ? { ...state, ...view() } : state,
116
+ refreshView: () => { if (snapshot) render(true); if (lastError) options.onError?.(lastError); },
117
+ destroy() {
118
+ disposed = true;
119
+ if (subscribed) chromeApi.storage.onChanged.removeListener(onStorageChange);
120
+ }
121
+ });
122
+ }
123
+
124
+ return Object.freeze({ create });
125
+ });
@@ -27,6 +27,8 @@
27
27
  'DiffReviewPanel',
28
28
  'FailureReasons',
29
29
  'GovernanceRules',
30
+ 'GlobalPreferences',
31
+ 'GlobalPreferencesController',
30
32
  'I18n',
31
33
  'LineReferences',
32
34
  'LocalSkillsPanel',
@@ -55,6 +57,8 @@
55
57
  'RunInputQueue',
56
58
  'RunQueueScheduler',
57
59
  'RunResultActions',
60
+ 'RunScrollLayout',
61
+ 'RunFailureNotice',
58
62
  'RunSettlementPersistence',
59
63
  'RunTimelineView',
60
64
  'ScopedPersistenceCoordinator',
@@ -50,6 +50,7 @@
50
50
  } = deps;
51
51
 
52
52
  let currentOtStatus = 'off';
53
+ let otTogglePending = false;
53
54
  let otSyncRequestId = 0;
54
55
  let otWarmMirrorProjectId = '';
55
56
  let lastExperimentalOtProjectId = '';
@@ -61,7 +62,10 @@
61
62
  patchQueue: [],
62
63
  lastStatus: 'off',
63
64
  lastPatchAt: 0,
64
- lastErrorCode: ''
65
+ lastErrorCode: '',
66
+ failureActive: false,
67
+ lastFailure: null,
68
+ suspended: false
65
69
  };
66
70
  let mirrorPrefetchState = {
67
71
  inFlight: null,
@@ -159,14 +163,14 @@
159
163
  }
160
164
 
161
165
  async function handleExperimentalOtToggleClick(event) {
162
- // The handler is bound to the visible checkbox: by the time click fires,
163
- // pre-click activation has ALREADY flipped .checked — that flipped value
164
- // is the user's intended target. preventDefault then rolls the visual
165
- // state back (synchronously, when dispatch completes), and the confirm
166
- // flow applies the target only after approval.
166
+ // Capture the pre-activated target, then let cancelled click dispatch
167
+ // restore the old checkbox value before projecting the approved state.
167
168
  const clicked = event?.currentTarget || event?.target;
168
169
  const targetEnabled = clicked && typeof clicked.checked === 'boolean' ? clicked.checked : undefined;
170
+ const projectId = getCurrentProjectId();
169
171
  event.preventDefault();
172
+ await new Promise(resolve => window.setTimeout(resolve, 0));
173
+ if (projectId !== getCurrentProjectId()) return;
170
174
  await toggleExperimentalOtCheckbox(targetEnabled);
171
175
  }
172
176
 
@@ -180,9 +184,12 @@
180
184
 
181
185
  async function toggleExperimentalOtCheckbox(targetEnabled) {
182
186
  const checkbox = getPanel()?.querySelector('[data-experimental-ot]');
183
- if (!checkbox) {
187
+ if (!checkbox || otTogglePending) {
184
188
  return;
185
189
  }
190
+ const projectId = getCurrentProjectId();
191
+ otTogglePending = true;
192
+ try {
186
193
  // Explicit target from the click interceptor wins; the keyboard/legacy
187
194
  // path (no argument, un-flipped checkbox) falls back to inversion.
188
195
  const nextEnabled = typeof targetEnabled === 'boolean' ? targetEnabled : !checkbox.checked;
@@ -200,11 +207,15 @@
200
207
  } else {
201
208
  closeDiagnosticsMenu();
202
209
  }
210
+ if (projectId !== getCurrentProjectId() || checkbox !== getPanel()?.querySelector('[data-experimental-ot]')) return;
203
211
  checkbox.checked = nextEnabled;
204
212
  handleExperimentalOtToggleChange({ currentTarget: checkbox });
205
213
  if (nextEnabled) {
206
214
  showPluginToast(tr('experimentalOtEnabledToast'), { status: 'info' });
207
215
  }
216
+ } finally {
217
+ otTogglePending = false;
218
+ }
208
219
  }
209
220
 
210
221
  function handleExperimentalOtToggleChange(event) {
@@ -213,6 +224,7 @@
213
224
  return;
214
225
  }
215
226
  const projectId = getCurrentProjectId();
227
+ otWarmMirrorState.failureActive = false;
216
228
  lastExperimentalOtProjectId = projectId;
217
229
  setExperimentalOtEnabledForProject(projectId, checkbox.checked);
218
230
  updateExperimentalOtToggleControl(checkbox.checked);
@@ -230,7 +242,7 @@
230
242
  updateOtStatusDisplay(checkbox.checked ? (currentOtStatus === 'off' ? 'starting' : currentOtStatus) : 'off');
231
243
  syncOtWarmMirrorController().catch(error => {
232
244
  updateOtStatusDisplay('unavailable');
233
- appendPlainLog(tx(`Experimental OT warm mirror unavailable: ${error.message}`, `实验性 OT 预热镜像不可用:${error.message}`));
245
+ appendPlainLog(tx(`OT warm mirror unavailable: ${error.message}`, `OT 预热镜像不可用:${error.message}`));
234
246
  });
235
247
  saveStateSoon();
236
248
  }
@@ -252,6 +264,7 @@
252
264
  otWarmMirrorProjectId = projectId;
253
265
  ensureOtWarmMirrorStateProject(projectId);
254
266
  if (enabled) {
267
+ otWarmMirrorState.suspended = false;
255
268
  const pause = otWarmMirrorController?.shouldPauseOtWarmMirror?.({ running: Boolean(getCurrentRunView()) }) || { pause: false };
256
269
  if (pause.pause) {
257
270
  clearOtEventPolling({ clearPatchQueue: true });
@@ -263,9 +276,12 @@
263
276
  } else {
264
277
  clearOtEventPolling({ clearPatchQueue: true });
265
278
  }
266
- const response = enabled
267
- ? await callPageBridge('startOtObserver', { projectId })
268
- : await callPageBridge('stopOtObserver', {});
279
+ let response;
280
+ try {
281
+ response = enabled ? await callPageBridge('startOtObserver', { projectId }) : await callPageBridge('stopOtObserver', {});
282
+ } catch (error) {
283
+ response = { ok: false, error: otWarmMirrorController.normalizeFailureCode(error?.code || error?.message, 'ot_bridge_unavailable') };
284
+ }
269
285
  if (!isCurrentOtSync(requestId, projectId) || getCurrentProjectId() !== projectId) {
270
286
  if (enabled) {
271
287
  await handleStaleOtStartResponse(projectId, requestId);
@@ -282,7 +298,7 @@
282
298
  }
283
299
  const status = readOtBridgeStatus(response);
284
300
  if (!isSuccessfulOtBridgeResponse(response) || status === 'unavailable') {
285
- handleFailedOtStart(projectId, requestId);
301
+ await handleFailedOtStart(projectId, requestId, response);
286
302
  return response;
287
303
  }
288
304
  const pauseAfterStart = otWarmMirrorController?.shouldPauseOtWarmMirror?.({ running: Boolean(getCurrentRunView()) }) || { pause: false };
@@ -290,6 +306,7 @@
290
306
  await pauseOtWarmMirror(pauseAfterStart.reason || 'paused');
291
307
  return response;
292
308
  }
309
+ otWarmMirrorState.failureActive = false;
293
310
  updateOtStatusDisplay(status);
294
311
  scheduleOtEventPolling(projectId, { immediate: true });
295
312
  return response;
@@ -314,11 +331,13 @@
314
331
  }
315
332
  }
316
333
 
317
- function handleFailedOtStart(projectId, requestId) {
334
+ async function handleFailedOtStart(projectId, requestId, response) {
318
335
  if (!isCurrentOtSync(requestId, projectId) || getCurrentProjectId() !== projectId) {
319
336
  return;
320
337
  }
321
338
  clearOtEventPolling({ clearPatchQueue: true });
339
+ recordOtFailure(readOtBridgeErrorCode(response, 'ot_start_failed'), 'start');
340
+ otWarmMirrorState.suspended = true;
322
341
  setExperimentalOtEnabledForProject(projectId, false);
323
342
  const experimentalOtCheckbox = getPanel()?.querySelector('[data-experimental-ot]');
324
343
  if (experimentalOtCheckbox) {
@@ -326,13 +345,14 @@
326
345
  }
327
346
  updateOtStatusDisplay('unavailable');
328
347
  saveStateSoon();
348
+ try { await callPageBridge('stopOtObserver', { projectId }); } catch (_error) { /* preserve the original failure */ }
329
349
  }
330
350
 
331
351
  function readOtBridgeStatus(response) {
332
352
  if (!response || response.ok === false) {
333
353
  return 'unavailable';
334
354
  }
335
- return response.getState() || response.status || response.result?.getState() || response.result?.status || 'unavailable';
355
+ return response.state || response.status || response.result?.state || response.result?.status || 'unavailable';
336
356
  }
337
357
 
338
358
  function ensureOtWarmMirrorStateProject(projectId) {
@@ -343,6 +363,9 @@
343
363
  otWarmMirrorState.projectId = projectId;
344
364
  otWarmMirrorState.lastPatchAt = 0;
345
365
  otWarmMirrorState.lastErrorCode = '';
366
+ otWarmMirrorState.failureActive = false;
367
+ otWarmMirrorState.lastFailure = null;
368
+ otWarmMirrorState.suspended = false;
346
369
  otWarmMirrorState.lastStatus = currentOtStatus;
347
370
  }
348
371
 
@@ -386,7 +409,8 @@
386
409
  && otWarmMirrorController?.buildPatchFilesRequest
387
410
  && isExperimentalOtEnabledForProject(projectId)
388
411
  && otWarmMirrorProjectId === projectId
389
- && getCurrentProjectId() === projectId);
412
+ && getCurrentProjectId() === projectId
413
+ && !otWarmMirrorState.suspended);
390
414
  }
391
415
 
392
416
  async function pollOtEvents(projectId = otWarmMirrorState.projectId || getCurrentProjectId()) {
@@ -409,11 +433,12 @@
409
433
  return { ok: false, skipped: true, reason: 'project_changed' };
410
434
  }
411
435
  if (!isSuccessfulOtBridgeResponse(statusResponse)) {
412
- otWarmMirrorState.lastErrorCode = readOtBridgeErrorCode(statusResponse, 'get_ot_status_failed');
436
+ recordOtFailure(readOtBridgeErrorCode(statusResponse, 'get_ot_status_failed'), 'observe');
413
437
  updateOtStatusDisplay('unavailable');
414
438
  return statusResponse;
415
439
  }
416
440
  const status = readOtBridgeStatus(statusResponse);
441
+ if (['unavailable', 'inconsistent'].includes(status)) recordOtFailure(readOtBridgeErrorCode(statusResponse, 'ot_observer_unavailable'), 'observe');
417
442
  if (status) {
418
443
  updateOtStatusDisplay(status);
419
444
  }
@@ -423,7 +448,7 @@
423
448
  return { ok: false, skipped: true, reason: 'project_changed' };
424
449
  }
425
450
  if (!isSuccessfulOtBridgeResponse(drainResponse)) {
426
- otWarmMirrorState.lastErrorCode = readOtBridgeErrorCode(drainResponse, 'drain_ot_events_failed');
451
+ recordOtFailure(readOtBridgeErrorCode(drainResponse, 'drain_ot_events_failed'), 'observe');
427
452
  updateOtStatusDisplay('unavailable');
428
453
  return drainResponse;
429
454
  }
@@ -433,7 +458,8 @@
433
458
  }
434
459
  return drainResponse;
435
460
  } catch (error) {
436
- otWarmMirrorState.lastErrorCode = error?.code || error?.message || 'ot_poll_failed';
461
+ if (!canPollOtWarmMirror(projectId)) return { ok: false, skipped: true, reason: 'project_changed' };
462
+ recordOtFailure(error?.code || error?.message || 'ot_poll_failed', 'observe');
437
463
  updateOtStatusDisplay('unavailable');
438
464
  return { ok: false, error };
439
465
  } finally {
@@ -465,12 +491,28 @@
465
491
  return typeof value === 'string' ? value : fallback;
466
492
  }
467
493
 
494
+ function recordOtFailure(code, phase) {
495
+ code = otWarmMirrorController.normalizeFailureCode(code);
496
+ otWarmMirrorState.lastErrorCode = code;
497
+ otWarmMirrorState.failureActive = true;
498
+ otWarmMirrorState.lastFailure = { code, phase, at: Date.now() };
499
+ }
500
+
468
501
  function queueOtPatchEvents(events, projectId = getCurrentProjectId()) {
469
502
  if (!Array.isArray(events) || !events.length || !canPollOtWarmMirror(projectId)) {
470
503
  return;
471
504
  }
472
505
  ensureOtWarmMirrorStateProject(projectId);
473
- otWarmMirrorState.patchQueue.push(...events);
506
+ const queued = otWarmMirrorController.queuePatchEvents(otWarmMirrorState.patchQueue, events);
507
+ if (!queued.ok) {
508
+ recordOtFailure(queued.reason, 'queue');
509
+ clearOtEventPolling({ clearPatchQueue: true });
510
+ otWarmMirrorState.suspended = true;
511
+ updateOtStatusDisplay('inconsistent');
512
+ void callPageBridge('stopOtObserver', { projectId }).catch(() => {});
513
+ return;
514
+ }
515
+ otWarmMirrorState.patchQueue = queued.queue;
474
516
  scheduleOtPatchFlush(projectId);
475
517
  }
476
518
 
@@ -510,11 +552,18 @@
510
552
  return { ok: false, skipped: true, reason: pause.reason || 'paused' };
511
553
  }
512
554
 
513
- const maxBatch = Math.max(1, Number(otWarmMirrorController?.OT_MAX_PATCH_BATCH) || 25);
514
- const batch = otWarmMirrorState.patchQueue.splice(0, maxBatch);
555
+ let batch = otWarmMirrorController.takePatchBatch(otWarmMirrorState.patchQueue);
515
556
  if (!batch.length) {
516
557
  return { ok: true, skipped: true, reason: 'empty_batch' };
517
558
  }
559
+ const fresh = otWarmMirrorController.queuePatchEvents([], batch);
560
+ if (!fresh.ok) {
561
+ recordOtFailure(fresh.reason, 'queue');
562
+ clearOtEventPolling({ clearPatchQueue: true });
563
+ updateOtStatusDisplay('inconsistent');
564
+ return { ok: false, skipped: true, reason: fresh.reason };
565
+ }
566
+ batch = fresh.queue;
518
567
 
519
568
  otWarmMirrorState.flushing = true;
520
569
  try {
@@ -530,46 +579,37 @@
530
579
  }
531
580
 
532
581
  const response = await sendBackgroundNative(request);
582
+ if (!canPollOtWarmMirror(projectId)) return { ok: false, skipped: true, reason: 'project_changed' };
533
583
  if (!response?.ok) {
534
- otWarmMirrorState.lastErrorCode = response?.error?.code || response?.error?.message || 'mirror_patch_failed';
584
+ recordOtFailure(response?.error?.code || 'mirror_patch_failed', 'flush');
535
585
  updateOtStatusDisplay('inconsistent');
536
586
  return response;
537
587
  }
538
588
 
539
- const result = response.result || {};
540
- const skippedFiles = Array.isArray(result?.skippedFiles) ? result.skippedFiles : [];
541
- const skippedCount = Number.isFinite(Number(result?.skippedCount))
542
- ? Number(result.skippedCount)
543
- : skippedFiles.length;
544
- if (skippedFiles.length || skippedCount > 0) {
545
- otWarmMirrorState.lastErrorCode = 'mirror_patch_skipped';
546
- updateOtStatusDisplay('inconsistent');
547
- return response;
548
- }
549
- const appliedFiles = Array.isArray(result?.appliedFiles) ? result.appliedFiles : null;
550
- const appliedCount = Number.isFinite(Number(result?.appliedCount))
551
- ? Number(result.appliedCount)
552
- : NaN;
553
- if (!appliedFiles || !Number.isFinite(appliedCount) || appliedCount <= 0 || appliedFiles.length !== appliedCount) {
554
- otWarmMirrorState.lastErrorCode = 'mirror_patch_invalid_result';
589
+ const receipt = otWarmMirrorController.validatePatchReceipt(batch, response.result);
590
+ if (!receipt.ok) {
591
+ recordOtFailure(receipt.reason, 'flush');
555
592
  updateOtStatusDisplay('inconsistent');
556
593
  return response;
557
594
  }
558
595
 
559
596
  otWarmMirrorState.lastPatchAt = Date.now();
560
597
  otWarmMirrorState.lastErrorCode = '';
561
- if (canPollOtWarmMirror(projectId)) {
598
+ otWarmMirrorState.failureActive = false;
599
+ if (canPollOtWarmMirror(projectId) && !getCurrentRunView()) {
562
600
  updateOtStatusDisplay('observing');
563
601
  }
564
602
  return response;
565
603
  } catch (error) {
566
- otWarmMirrorState.lastErrorCode = error?.code || error?.message || 'mirror_patch_failed';
604
+ if (!canPollOtWarmMirror(projectId)) return { ok: false, skipped: true, reason: 'project_changed' };
605
+ recordOtFailure(error?.code || 'mirror_patch_failed', 'flush');
567
606
  updateOtStatusDisplay('inconsistent');
568
607
  return { ok: false, error };
569
608
  } finally {
570
609
  otWarmMirrorState.flushing = false;
571
- if (otWarmMirrorState.patchQueue.length && canPollOtWarmMirror(projectId) && !getCurrentRunView()) {
572
- scheduleOtPatchFlush(projectId, { immediate: true });
610
+ const nextProjectId = otWarmMirrorState.projectId;
611
+ if (otWarmMirrorState.patchQueue.length && canPollOtWarmMirror(nextProjectId) && !getCurrentRunView()) {
612
+ scheduleOtPatchFlush(nextProjectId, { immediate: true });
573
613
  }
574
614
  }
575
615
  }
@@ -612,12 +652,13 @@
612
652
  }
613
653
 
614
654
  function updateOtStatusDisplay(status = currentOtStatus) {
615
- const normalized = normalizeOtStatus(status);
655
+ const normalized = otWarmMirrorState.failureActive && status === 'observing' ? 'inconsistent' : normalizeOtStatus(status);
616
656
  currentOtStatus = normalized;
617
657
  otWarmMirrorState.lastStatus = normalized;
618
658
  const statusElement = getPanel()?.querySelector('[data-ot-status]');
619
659
  if (statusElement) {
620
- const label = formatOtStatusLabel(normalized);
660
+ const label = formatOtStatusLabel(normalized) + (otWarmMirrorState.failureActive
661
+ ? ': ' + tr(otWarmMirrorController.getFailureMessageKey(otWarmMirrorState.lastErrorCode)) : '');
621
662
  statusElement.textContent = formatOtToggleStatusText(label);
622
663
  statusElement.dataset.otStatus = normalized;
623
664
  statusElement.title = formatOtToggleStatusText(label);
@@ -781,38 +822,24 @@
781
822
  }
782
823
 
783
824
  async function resolveWarmRunStart(taskContext = {}) {
825
+ const projectId = getCurrentProjectId();
784
826
  const focusFiles = taskContext.focusFiles || [];
785
827
  const mode = taskContext.mode || getState()?.mode;
786
828
  const mirrorStatus = await getMirrorFreshness();
829
+ if (getCurrentProjectId() !== projectId) return { useExistingMirror: false, reason: 'project_changed' };
787
830
  const otWarmStart = otWarmMirrorController.canUseOtWarmStart({
788
- enabled: isExperimentalOtEnabled(),
831
+ enabled: isExperimentalOtEnabled() && !otWarmMirrorState.failureActive,
789
832
  focusFiles,
790
833
  mirrorStatus
791
834
  });
792
- if (otWarmStart.ok) {
793
- return {
794
- useExistingMirror: true,
795
- warmStart: true,
796
- otWarmStart: true,
797
- reason: 'ot_focus_fresh',
798
- mirrorStatus,
799
- focusFiles: otWarmStart.focusFiles,
800
- project: {
801
- capabilities: {
802
- fullProjectSnapshot: false,
803
- method: 'ot-warm-mirror'
804
- },
805
- files: []
806
- }
807
- };
808
- }
809
- if (!mirrorStatus?.exists || !isMirrorReusable(mirrorStatus)) {
835
+ if (!mirrorStatus?.exists || (!otWarmStart.ok && !isMirrorReusable(mirrorStatus))) {
810
836
  return { useExistingMirror: false, reason: 'mirror_not_fresh', mirrorStatus };
811
837
  }
812
838
 
813
839
  let overlayProject = null;
814
840
  try {
815
841
  overlayProject = await callPageBridge('getProjectSnapshot', {
842
+ projectId,
816
843
  force: true,
817
844
  maxAgeMs: 0,
818
845
  preferLightweight: true,
@@ -826,6 +853,7 @@
826
853
  } catch (error) {
827
854
  return { useExistingMirror: false, reason: 'overlay_probe_failed', mirrorStatus };
828
855
  }
856
+ if (getCurrentProjectId() !== projectId) return { useExistingMirror: false, reason: 'project_changed' };
829
857
  const fileOverlays = buildSnapshotFileOverlays(overlayProject, focusFiles);
830
858
  if (!fileOverlays.length) {
831
859
  return { useExistingMirror: false, reason: 'missing_current_overlay', mirrorStatus };
@@ -840,7 +868,9 @@
840
868
  return {
841
869
  useExistingMirror: true,
842
870
  warmStart: true,
843
- reason: mode === 'ask' ? 'warm_whole_project_ask' : 'warm_whole_project',
871
+ otWarmStart: otWarmStart.ok,
872
+ focusFiles,
873
+ reason: otWarmStart.ok ? 'ot_focus_fresh' : mode === 'ask' ? 'warm_whole_project_ask' : 'warm_whole_project',
844
874
  mirrorStatus,
845
875
  fileOverlays,
846
876
  project: overlayProject
@@ -928,7 +958,7 @@
928
958
  return syncOtWarmMirrorController();
929
959
  }).catch(error => {
930
960
  updateOtStatusDisplay('unavailable');
931
- appendPlainLog(tx(`Experimental OT warm mirror unavailable: ${error.message}`, `实验性 OT 预热镜像不可用:${error.message}`));
961
+ appendPlainLog(tx(`OT warm mirror unavailable: ${error.message}`, `OT 预热镜像不可用:${error.message}`));
932
962
  });
933
963
  }
934
964