codex-overleaf-link 2.3.5 → 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 (33) hide show
  1. package/README.md +31 -24
  2. package/extension/bootstrap/manifest.template.json +1 -1
  3. package/extension/src/background.js +4 -0
  4. package/extension/src/backgroundUpdateCoordinator.js +88 -4
  5. package/extension/src/content/contentRuntime.js +24 -7
  6. package/extension/src/content/diagnosticsController.js +13 -3
  7. package/extension/src/content/diagnosticsPanel.js +1 -1
  8. package/extension/src/content/generated/content.bundle.js +848 -245
  9. package/extension/src/content/generated/content.bundle.meta.json +6 -3
  10. package/extension/src/content/legacyGlobalRegistry.js +2 -0
  11. package/extension/src/content/otWarmMirror.js +95 -65
  12. package/extension/src/content/otWarmMirrorController.js +102 -1
  13. package/extension/src/content/panelMaintenance.js +26 -15
  14. package/extension/src/content/providerSettingsCoordinator.js +1 -0
  15. package/extension/src/content/runFailureNotice.js +104 -0
  16. package/extension/src/content/runScrollLayout.js +124 -0
  17. package/extension/src/content/runTimelineView.js +36 -10
  18. package/extension/src/content/settingsPanel.js +28 -11
  19. package/extension/src/content/updateNotice.js +24 -4
  20. package/extension/src/page/overleafRealtimeObserver.js +116 -24
  21. package/extension/src/shared/compatibility.js +1 -1
  22. package/extension/src/shared/i18n.js +77 -37
  23. package/extension/src/shared/managedUpdateProjection.js +1 -1
  24. package/extension/src/shared/otText.js +0 -1
  25. package/extension/src/shared/sessionState.js +17 -21
  26. package/extension/src/shared/sessionTitle.js +120 -0
  27. package/extension/src/shared/updateRuntimeIdentity.js +32 -0
  28. package/extension/styles/panel.css +73 -14
  29. package/native-host/src/mirrorWorkspace.js +20 -1
  30. package/native-host/src/releaseProxy.js +177 -0
  31. package/native-host/src/releaseTransport.js +154 -0
  32. package/native-host/src/updateManager.js +40 -39
  33. package/package.json +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": "7ed22d5c82b20220316824547e1dfc359e45dfcdf1f1bca2fb7280195a05361c",
6
- "outputDigest": "50436e35fe942b3fd8540a6267fba79c41a53c216b1ffc42995e4ed026771de4",
7
- "byteLength": 2337940,
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",
@@ -44,10 +44,12 @@
44
44
  "extension/src/content/recentProjects.js",
45
45
  "extension/src/content/reviewHunks.js",
46
46
  "extension/src/content/runController.js",
47
+ "extension/src/content/runFailureNotice.js",
47
48
  "extension/src/content/runGuidanceController.js",
48
49
  "extension/src/content/runGuidanceView.js",
49
50
  "extension/src/content/runQueueScheduler.js",
50
51
  "extension/src/content/runResultActions.js",
52
+ "extension/src/content/runScrollLayout.js",
51
53
  "extension/src/content/runSettlementPersistence.js",
52
54
  "extension/src/content/runTimelineView.js",
53
55
  "extension/src/content/scopedPersistenceCoordinator.js",
@@ -90,6 +92,7 @@
90
92
  "extension/src/shared/scopedPersistenceTransaction.js",
91
93
  "extension/src/shared/sensitiveScan.js",
92
94
  "extension/src/shared/sessionState.js",
95
+ "extension/src/shared/sessionTitle.js",
93
96
  "extension/src/shared/settlementFacts.js",
94
97
  "extension/src/shared/staleGuard.js",
95
98
  "extension/src/shared/storageDb.js",
@@ -57,6 +57,8 @@
57
57
  'RunInputQueue',
58
58
  'RunQueueScheduler',
59
59
  'RunResultActions',
60
+ 'RunScrollLayout',
61
+ 'RunFailureNotice',
60
62
  'RunSettlementPersistence',
61
63
  'RunTimelineView',
62
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
 
@@ -10,6 +10,12 @@
10
10
  const OT_POLL_INTERVAL_MS = 1000;
11
11
  const OT_PATCH_DEBOUNCE_MS = 500;
12
12
  const OT_MAX_PATCH_BATCH = 25;
13
+ const OT_FRESHNESS_MAX_AGE_MS = 30000;
14
+ const OT_MAX_FILE_BYTES = 1024 * 1024;
15
+ const OT_MAX_PENDING_EVENTS = 64;
16
+ const OT_MAX_PENDING_BYTES = 4 * 1024 * 1024;
17
+ const OT_MAX_BATCH_BYTES = 2 * 1024 * 1024;
18
+ const encoder = typeof TextEncoder === 'function' ? new TextEncoder() : null;
13
19
  const PAUSE_STATES = new Set(['running', 'writing', 'undoing', 'compiling']);
14
20
 
15
21
  function buildPatchFilesRequest({ projectId, events, nativeCompatibility } = {}) {
@@ -67,13 +73,14 @@
67
73
  return { pause: false };
68
74
  }
69
75
 
70
- function canUseOtWarmStart({ enabled, focusFiles, mirrorStatus } = {}) {
76
+ function canUseOtWarmStart({ enabled, focusFiles, mirrorStatus, now = Date.now() } = {}) {
71
77
  if (!enabled) {
72
78
  return { ok: false, reason: 'disabled' };
73
79
  }
74
80
  if (!mirrorStatus || mirrorStatus.exists !== true) {
75
81
  return { ok: false, reason: 'mirror_missing' };
76
82
  }
83
+ if (mirrorStatus.dirty === true) return { ok: false, reason: 'mirror_dirty' };
77
84
 
78
85
  const normalizedFocusFiles = normalizePaths(focusFiles);
79
86
  if (!normalizedFocusFiles.length) {
@@ -85,6 +92,8 @@
85
92
  if (file?.state !== 'fresh') {
86
93
  continue;
87
94
  }
95
+ const age = now - Date.parse(file.observedAt ?? file.lastPatchAt ?? '');
96
+ if (!Number.isFinite(age) || age < 0 || age > OT_FRESHNESS_MAX_AGE_MS) continue;
88
97
  const filePath = normalizePath(file.path);
89
98
  if (filePath) {
90
99
  freshFiles.add(filePath);
@@ -102,6 +111,88 @@
102
111
  return { ok: false, reason: 'missing_ot_fresh_focus' };
103
112
  }
104
113
 
114
+ function textBytes(value) {
115
+ const text = String(value || '');
116
+ return encoder ? encoder.encode(text).byteLength : text.length * 3;
117
+ }
118
+
119
+ function patchBytes(event) { return textBytes(event.nextContent) + textBytes(event.path) + 512; }
120
+
121
+ function queuePatchEvents(queue, events, now = Date.now()) {
122
+ if (!Array.isArray(events) || events.length > OT_MAX_PENDING_EVENTS) return { ok: false, reason: 'ot_queue_limit' };
123
+ const next = Array.isArray(queue) ? queue.slice() : [];
124
+ let bytes = next.reduce((sum, event) => sum + patchBytes(event), 0);
125
+ if (next.length > OT_MAX_PENDING_EVENTS || bytes > OT_MAX_PENDING_BYTES) return { ok: false, reason: 'ot_queue_limit' };
126
+ for (const input of events) {
127
+ const path = normalizePath(input?.path);
128
+ const time = typeof input?.observedAt === 'number' ? input.observedAt : Date.parse(input?.observedAt || '');
129
+ if (!isSafeRelativePath(path) || path.length > 1024 || typeof input?.nextContent !== 'string' ||
130
+ typeof input.baseHash !== 'string' || !input.baseHash || input.baseHash.length > 128) {
131
+ return { ok: false, reason: 'ot_event_invalid' };
132
+ }
133
+ if (!Number.isFinite(time) || now - time < 0 || now - time > OT_FRESHNESS_MAX_AGE_MS) return { ok: false, reason: 'ot_event_expired' };
134
+ if (textBytes(input.nextContent) > OT_MAX_FILE_BYTES) return { ok: false, reason: 'ot_file_limit' };
135
+ const event = { path, baseHash: input.baseHash, nextContent: input.nextContent,
136
+ nextHash: typeof input.nextHash === 'string' && input.nextHash.length <= 128 ? input.nextHash : '',
137
+ observedAt: new Date(time).toISOString() };
138
+ if (isPrimitiveMetadata(input.observedVersion) && String(input.observedVersion).length <= 128) event.observedVersion = input.observedVersion;
139
+ const tail = next[next.length - 1];
140
+ if (tail?.path === path && tail.nextHash && tail.nextHash === event.baseHash) {
141
+ event.baseHash = tail.baseHash;
142
+ bytes -= patchBytes(next.pop());
143
+ }
144
+ if (event.nextHash !== event.baseHash) { next.push(event); bytes += patchBytes(event); }
145
+ if (next.length > OT_MAX_PENDING_EVENTS || bytes > OT_MAX_PENDING_BYTES) return { ok: false, reason: 'ot_queue_limit' };
146
+ }
147
+ return { ok: true, queue: next, bytes };
148
+ }
149
+
150
+ function takePatchBatch(queue) {
151
+ let bytes = 0;
152
+ let count = 0;
153
+ while (count < Math.min(queue.length, OT_MAX_PATCH_BATCH)) {
154
+ const size = patchBytes(queue[count]);
155
+ if (bytes + size > OT_MAX_BATCH_BYTES) break;
156
+ bytes += size;
157
+ count += 1;
158
+ }
159
+ return queue.splice(0, count);
160
+ }
161
+
162
+ function validatePatchReceipt(events, result = {}, now = Date.now()) {
163
+ if ((Array.isArray(result.skippedFiles) && result.skippedFiles.length) || Number(result.skippedCount || 0) > 0) {
164
+ return { ok: false, reason: 'mirror_patch_skipped' };
165
+ }
166
+ if (!events.length || Number(result.appliedCount) !== events.length || !Array.isArray(result.appliedFiles) ||
167
+ result.appliedFiles.length !== events.length || !result.appliedFiles.every((file, index) =>
168
+ normalizePath(file?.path) === events[index].path && (!events[index].nextHash || file.hash === events[index].nextHash))) {
169
+ return { ok: false, reason: 'mirror_patch_invalid_result' };
170
+ }
171
+ if (events.some(event => { const time = Date.parse(event.observedAt);
172
+ return !Number.isFinite(time) || time > now || now - time > OT_FRESHNESS_MAX_AGE_MS;
173
+ })) return { ok: false, reason: 'ot_event_expired' };
174
+ return { ok: true };
175
+ }
176
+
177
+ function normalizeFailureCode(value, fallback = 'ot_error') {
178
+ const code = typeof value === 'string' ? value : value?.code;
179
+ if (/^[a-z][a-z0-9_]{0,79}$/.test(code || '')) return code;
180
+ if (/page bridge|capability|bridge timed out/i.test(String(code || ''))) return 'ot_bridge_unavailable';
181
+ return fallback;
182
+ }
183
+
184
+ function getFailureMessageKey(value) {
185
+ const code = normalizeFailureCode(value);
186
+ if (/bridge|ot_status|ot_events/.test(code)) return 'otFailureBridge';
187
+ if (/missing_ot_text|observer_create|observer_unavailable/.test(code)) return 'otFailureObserver';
188
+ if (code === 'ot_file_limit') return 'otFailureFileLimit';
189
+ if (code === 'ot_queue_limit') return 'otFailureQueueLimit';
190
+ if (code === 'ot_event_expired') return 'otFailureExpired';
191
+ if (/editor|active_path|document/.test(code)) return 'otFailureEditor';
192
+ if (/mirror|hash|baseline/.test(code)) return 'otFailureMirror';
193
+ return 'otFailureGeneric';
194
+ }
195
+
105
196
  function normalizePath(value) {
106
197
  return String(value ?? '')
107
198
  .trim()
@@ -165,6 +256,16 @@
165
256
  OT_POLL_INTERVAL_MS,
166
257
  OT_PATCH_DEBOUNCE_MS,
167
258
  OT_MAX_PATCH_BATCH,
259
+ OT_FRESHNESS_MAX_AGE_MS,
260
+ OT_MAX_FILE_BYTES,
261
+ OT_MAX_PENDING_EVENTS,
262
+ OT_MAX_PENDING_BYTES,
263
+ OT_MAX_BATCH_BYTES,
264
+ queuePatchEvents,
265
+ takePatchBatch,
266
+ validatePatchReceipt,
267
+ normalizeFailureCode,
268
+ getFailureMessageKey,
168
269
  buildPatchFilesRequest,
169
270
  canUseOtWarmStart,
170
271
  normalizePath,
@@ -129,29 +129,40 @@
129
129
  }
130
130
 
131
131
  // --- History & storage (v1.7.5): usage summary + clear-all in Settings ---
132
+ let storageUsageRequestId = 0;
132
133
  function refreshStorageUsageSummary() {
133
134
  const node = getSettingsPanelInstance()?.container?.querySelector('[data-storage-usage]');
134
135
  if (!node) {
135
136
  return;
136
137
  }
137
- const sessionCount = Array.isArray(getState()?.sessions) ? getState().sessions.length : 0;
138
- const runCount = Array.isArray(getState()?.runs) ? getState().runs.length : 0;
138
+ const requestId = ++storageUsageRequestId;
139
+ const projectId = getCurrentProjectId?.();
140
+ const isCurrent = () => requestId === storageUsageRequestId && projectId === getCurrentProjectId?.()
141
+ && getSettingsPanelInstance()?.container?.querySelector('[data-storage-usage]') === node;
142
+ const current = getState();
143
+ const sessionCount = Array.isArray(current?.sessions) ? current.sessions.length : 0;
144
+ const runCount = Array.isArray(current?.runs) ? current.runs.length : 0;
139
145
  const counts = tx(
140
- `${sessionCount} session(s) · ${runCount} recent run(s) kept`,
141
- `${sessionCount} 个会话 · 保留最近 ${runCount} 轮运行`
146
+ `${sessionCount} loaded session(s) in this project · ${runCount} run(s) in the active session`,
147
+ `当前项目已加载 ${sessionCount} 个会话 · 当前会话 ${runCount} 轮运行`
142
148
  );
149
+ // Dynamic text belongs to this renderer, not the loading-label translator.
150
+ node.removeAttribute?.('data-i18n');
143
151
  node.textContent = counts;
144
- const estimate = navigator.storage?.estimate?.();
145
- if (estimate?.then) {
146
- estimate.then(info => {
147
- const usage = Number(info?.usage);
148
- if (Number.isFinite(usage) && usage >= 0) {
149
- const mb = usage / (1024 * 1024);
150
- const size = mb >= 1 ? `${mb.toFixed(1)} MB` : `${Math.max(1, Math.round(usage / 1024))} KB`;
151
- node.textContent = tx(`${counts} · ~${size} used on this site`, `${counts} · 本站点约占用 ${size}`);
152
- }
153
- }).catch(() => { /* estimate unsupported — counts alone are fine */ });
154
- }
152
+ const unavailable = () => {
153
+ if (isCurrent()) node.textContent = tx(`${counts} · Site storage estimate unavailable`, `${counts} · 本站点容量暂无法估算`);
154
+ };
155
+ let timer;
156
+ const estimate = Promise.resolve().then(() => navigator.storage?.estimate?.());
157
+ const deadline = new Promise(resolve => { timer = setTimeout(() => resolve(null), 2000); });
158
+ return Promise.race([estimate, deadline]).then(info => {
159
+ if (!isCurrent()) return;
160
+ const usage = info?.usage;
161
+ if (!Number.isFinite(usage) || usage < 0) { unavailable(); return; }
162
+ const mb = usage / (1024 * 1024);
163
+ const size = usage === 0 ? '0 KB' : mb >= 1 ? `${mb.toFixed(1)} MB` : `${Math.max(1, Math.round(usage / 1024))} KB`;
164
+ node.textContent = tx(`${counts} · Site total ~${size}`, `${counts} · 本站点总占用约 ${size}`);
165
+ }).catch(unavailable).finally(() => clearTimeout(timer));
155
166
  }
156
167
 
157
168
  async function clearAllHistoryWithConfirm() {
@@ -59,6 +59,7 @@
59
59
  return {
60
60
  open: () => open(instance),
61
61
  refreshSummary: () => refreshSummary(instance),
62
+ renderSummary: () => updateSettingsSummary(instance),
62
63
  ensureLoaded: () => ensureLoaded(instance),
63
64
  getRunSelection: providerId => instance.loaded
64
65
  ? Profiles.buildRunSelection(instance.catalog, providerId || instance.getSelectedProviderId())