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
@@ -0,0 +1,104 @@
1
+ (function initRunFailureNotice(root, factory) {
2
+ if (typeof module === 'object' && module.exports) {
3
+ module.exports = factory();
4
+ } else {
5
+ root.CodexOverleafModuleRegistry.define('RunFailureNotice', [], factory);
6
+ }
7
+ })(typeof window !== 'undefined' ? window : globalThis, function runFailureNoticeFactory() {
8
+ 'use strict';
9
+
10
+ const REVIEW_CODES = new Set(['write_timeout', 'partial_write_needs_review']);
11
+ const CANCEL_CODES = new Set(['cancelled', 'user_cancelled', 'codex_cancelled']);
12
+
13
+ function create({ tr, projectRunSettlement, sanitizeText = value => value,
14
+ document: documentRef = globalThis.document } = {}) {
15
+ const notices = new WeakMap();
16
+
17
+ function append(report, event = {}, run = {}) {
18
+ const projection = typeof projectRunSettlement === 'function' && run
19
+ ? projectRunSettlement(run) : null;
20
+ const failure = projection?.primaryFailure || event.failure;
21
+ if (report.dataset.status === 'cancelled' || CANCEL_CODES.has(failure?.code)) return;
22
+ const facts = run?.settlement || run?.settlementFacts || {};
23
+ const unconfirmed = REVIEW_CODES.has(failure?.code)
24
+ || facts.documentEffect === 'possibly-changed'
25
+ || (facts.evidence?.applied === 'unknown' && facts.evidence?.settled === 'needs-review');
26
+ const partial = facts.evidence?.applied === 'partial' || run?.partialWriteback === true;
27
+ if (!failure && !unconfirmed && !partial && report.dataset.status !== 'failed') return;
28
+
29
+ const notice = documentRef.createElement('aside');
30
+ notice.className = 'run-feedback';
31
+ notice.dataset.tone = unconfirmed || partial || failure?.terminalState === 'degraded'
32
+ || failure?.terminalState === 'needs_review' ? 'warning' : 'error';
33
+ notice.setAttribute('role', 'note');
34
+ const heading = documentRef.createElement('strong');
35
+ heading.className = 'run-feedback__title';
36
+ heading.textContent = tr(unconfirmed ? 'runFeedbackUnconfirmedTitle'
37
+ : partial ? 'runFeedbackPartialTitle' : 'runFeedbackFailedTitle');
38
+ const message = documentRef.createElement('p');
39
+ const detail = typeof failure?.userMessage === 'string' ? failure.userMessage : '';
40
+ const file = safeProjectPath(failure?.file);
41
+ message.textContent = sanitizeText([file, detail].filter(Boolean).join(': ').slice(0, 1000))
42
+ || tr('runFeedbackGeneric');
43
+ notice.append(heading, message);
44
+ if (unconfirmed) {
45
+ const warning = documentRef.createElement('p');
46
+ warning.textContent = tr('runFeedbackUnconfirmed');
47
+ notice.append(warning);
48
+ }
49
+ notices.set(report, { notice, unconfirmed, failure, file });
50
+ report.append(notice);
51
+ }
52
+
53
+ function formatMetaValue(report, row) {
54
+ if (!notices.get(report)?.unconfirmed) return row.value;
55
+ const key = String(row.key || '').toLowerCase().replace(/[^a-z]/g, '');
56
+ const label = String(row.label || '').trim().toLowerCase();
57
+ if (key === 'writeresult' || key === 'next'
58
+ || ['write result', 'next', '\u5199\u5165\u7ed3\u679c', '\u4e0b\u4e00\u6b65'].includes(label)) {
59
+ return tr('runFeedbackUnconfirmedResult');
60
+ }
61
+ return row.value;
62
+ }
63
+
64
+ function prepareRecovery(report, { openFile } = {}) {
65
+ const info = notices.get(report);
66
+ const target = info?.notice || report;
67
+ if (!info?.unconfirmed) return { target, handled: false };
68
+ const canOpen = Boolean(info.file && typeof openFile === 'function');
69
+ const button = documentRef.createElement('button');
70
+ button.type = 'button';
71
+ button.className = 'run-final-answer__recovery-action';
72
+ button.dataset.recoveryFor = info.failure?.code || 'write_unconfirmed';
73
+ button.textContent = tr(canOpen ? 'runFeedbackReviewFile' : 'runFeedbackReviewDetails', { file: info.file });
74
+ button.addEventListener('click', event => {
75
+ event.stopPropagation();
76
+ if (canOpen) {
77
+ openFile(info.file);
78
+ return;
79
+ }
80
+ const process = report.closest?.('[data-run-id]')?.querySelector?.('[data-run-process]');
81
+ if (process) {
82
+ process.open = true;
83
+ process.scrollIntoView?.({ block: 'nearest' });
84
+ } else {
85
+ button.disabled = true;
86
+ button.title = tr('runFeedbackDetailsUnavailable');
87
+ }
88
+ });
89
+ target.append(button);
90
+ return { target, handled: true };
91
+ }
92
+
93
+ return Object.freeze({ append, formatMetaValue, prepareRecovery });
94
+ }
95
+
96
+ function safeProjectPath(value) {
97
+ if (typeof value !== 'string') return '';
98
+ const path = value.replace(/\\/g, '/');
99
+ return path && !path.startsWith('/') && !/^[A-Za-z]:/.test(path)
100
+ && !path.split('/').includes('..') ? path.slice(0, 300) : '';
101
+ }
102
+
103
+ return Object.freeze({ create });
104
+ });
@@ -0,0 +1,124 @@
1
+ (function initRunScrollLayout(root, factory) {
2
+ if (typeof module === 'object' && module.exports) {
3
+ module.exports = factory();
4
+ } else {
5
+ root.CodexOverleafModuleRegistry.define('RunScrollLayout', [], factory);
6
+ }
7
+ })(typeof window !== 'undefined' ? window : globalThis, function runScrollLayoutFactory() {
8
+ 'use strict';
9
+
10
+ const READING_ANCHORS = '.run-final-answer p, .run-final-answer li, .run-final-answer pre, .run-final-answer h1, .run-final-answer h2, .run-final-answer h3, .run-stream p, .run-stream li, .run-stream pre, [data-run-task], [data-run-status]';
11
+
12
+ function create({ getScroller, onLayoutChange, isFollowing = () => true, window: host = globalThis } = {}) {
13
+ let current = null;
14
+ let resizeObserver = null;
15
+ let mutationObserver = null;
16
+ let anchor = null;
17
+ let extent = '';
18
+
19
+ function measure() { return current ? `${current.scrollHeight}:${current.clientHeight}` : ''; }
20
+
21
+ function captureAnchor(force = false) {
22
+ if (!current || isFollowing()) { anchor = null; return; }
23
+ // A layout-induced scroll can arrive before ResizeObserver. Keep the
24
+ // pre-layout anchor until notify has compensated its displacement.
25
+ if (!force && extent !== measure()) return;
26
+ const bounds = current.getBoundingClientRect?.();
27
+ if (!bounds) return;
28
+ const nodes = current.querySelectorAll?.(READING_ANCHORS) || [];
29
+ anchor = null;
30
+ for (const node of nodes) {
31
+ const rect = node.getBoundingClientRect();
32
+ if (rect.height > 0 && rect.bottom > bounds.top && rect.top < bounds.bottom) {
33
+ anchor = { node, offset: rect.top - bounds.top };
34
+ break;
35
+ }
36
+ }
37
+ }
38
+
39
+ function restoreAnchor() {
40
+ if (isFollowing() || !anchor || !current.contains?.(anchor.node)) return;
41
+ const rect = anchor.node.getBoundingClientRect();
42
+ if (!rect.height) return;
43
+ const delta = rect.top - current.getBoundingClientRect().top - anchor.offset;
44
+ if (Math.abs(delta) > 0.5) current.scrollTop += delta;
45
+ }
46
+
47
+ function snapshot() {
48
+ captureAnchor();
49
+ const run = anchor?.node.closest?.('[data-run-id]');
50
+ return { scrollTop: current?.scrollTop || 0, runId: run?.dataset?.runId || '',
51
+ index: run ? Array.from(run.querySelectorAll(READING_ANCHORS)).indexOf(anchor.node) : -1,
52
+ offset: anchor?.offset || 0 };
53
+ }
54
+
55
+ function restore(reading) {
56
+ if (!current || !reading) return;
57
+ current.scrollTop = reading.scrollTop;
58
+ // History repaint replaces nodes. Rebind by run identity and paragraph
59
+ // position; a missing anchor falls back to the saved scroll offset.
60
+ const run = Array.from(current.querySelectorAll?.('[data-run-id]') || [])
61
+ .find(node => node.dataset.runId === reading.runId);
62
+ const node = run?.querySelectorAll(READING_ANCHORS)[reading.index];
63
+ anchor = node ? { node, offset: reading.offset } : null;
64
+ restoreAnchor();
65
+ extent = measure();
66
+ captureAnchor(true);
67
+ }
68
+
69
+ function dispose() {
70
+ resizeObserver?.disconnect();
71
+ mutationObserver?.disconnect();
72
+ resizeObserver = null;
73
+ mutationObserver = null;
74
+ current = null;
75
+ anchor = null;
76
+ extent = '';
77
+ }
78
+
79
+ function notify() {
80
+ if (!current) return;
81
+ const active = getScroller();
82
+ if (active !== current) {
83
+ bind(active);
84
+ return;
85
+ }
86
+ restoreAnchor();
87
+ extent = measure();
88
+ onLayoutChange(current);
89
+ captureAnchor(true);
90
+ }
91
+
92
+ function bind(scroller) {
93
+ if (scroller === current) return;
94
+ dispose();
95
+ if (!scroller || typeof host.ResizeObserver !== 'function') return;
96
+ current = scroller;
97
+ extent = measure();
98
+ captureAnchor(true);
99
+ resizeObserver = new host.ResizeObserver(notify);
100
+ resizeObserver.observe(scroller);
101
+ // Observe run-sized children, rather than every streaming text node.
102
+ for (const child of scroller.children || []) resizeObserver.observe(child);
103
+ if (typeof host.MutationObserver === 'function') {
104
+ mutationObserver = new host.MutationObserver(records => {
105
+ if (current !== scroller) return;
106
+ for (const record of records) {
107
+ for (const node of record.removedNodes) {
108
+ if (node.nodeType === 1) resizeObserver.unobserve(node);
109
+ }
110
+ for (const node of record.addedNodes) {
111
+ if (node.nodeType === 1) resizeObserver.observe(node);
112
+ }
113
+ }
114
+ notify();
115
+ });
116
+ mutationObserver.observe(scroller, { childList: true });
117
+ }
118
+ }
119
+
120
+ return Object.freeze({ bind, dispose, captureAnchor, snapshot, restore });
121
+ }
122
+
123
+ return Object.freeze({ create });
124
+ });
@@ -14,6 +14,8 @@
14
14
  const {
15
15
  RunGuidanceView,
16
16
  RunResultActions,
17
+ RunScrollLayout,
18
+ RunFailureNotice,
17
19
  tr,
18
20
  tx,
19
21
  getLocale,
@@ -48,6 +50,7 @@
48
50
 
49
51
  let logAutoFollow = true;
50
52
  let userScrollIntentUntil = 0;
53
+ let userScrollExtent = '';
51
54
  // Scroll engine: a single rAF coalesces a burst of scroll requests into one
52
55
  // write per frame (streaming can fire ~25/sec); `scrollLogPendingForce`
53
56
  // survives that coalesce so a forced scroll is never lost. `unreadSinceDetach`
@@ -60,6 +63,12 @@
60
63
  // reads a static "Processing…" and the user can't tell a working run from a
61
64
  // hung one — the single highest-value streaming signal per competitor UX.
62
65
  let runElapsedTimer = null;
66
+ const scrollLayout = RunScrollLayout?.create({
67
+ getScroller: getLogScrollContainer,
68
+ isFollowing: () => logAutoFollow,
69
+ onLayoutChange: scroller => logAutoFollow ? scrollLogToBottom() : updateJumpToLatestButton(scroller)
70
+ });
71
+ const failureNotice = RunFailureNotice?.create({ tr, projectRunSettlement, sanitizeText: sanitizeAssistantVisibleText });
63
72
 
64
73
  // Re-arm auto-follow (called by the runtime when a new run starts, so the
65
74
  // log snaps back to following the live stream).
@@ -70,6 +79,7 @@
70
79
 
71
80
  function bindLogAutoFollow() {
72
81
  const scroller = getLogScrollContainer();
82
+ scrollLayout?.bind(scroller);
73
83
  if (!scroller || scroller.dataset.autoFollowBound === 'true') {
74
84
  return;
75
85
  }
@@ -79,15 +89,14 @@
79
89
  scroller.addEventListener('pointerdown', markUserScrollIntent, { passive: true });
80
90
  scroller.addEventListener('keydown', event => {
81
91
  if (['ArrowUp', 'ArrowDown', 'PageUp', 'PageDown', 'Home', 'End', ' '].includes(event.key)) {
82
- markUserScrollIntent();
92
+ markUserScrollIntent(event);
83
93
  }
84
94
  });
85
95
  scroller.addEventListener('scroll', () => {
86
- if (Date.now() <= userScrollIntentUntil) {
96
+ if (Date.now() <= userScrollIntentUntil && userScrollExtent === `${scroller.scrollHeight}:${scroller.clientHeight}`) {
87
97
  logAutoFollow = isLogNearBottom(scroller);
88
- } else if (isLogNearBottom(scroller)) {
89
- logAutoFollow = true;
90
98
  }
99
+ scrollLayout?.captureAnchor();
91
100
  // Reveal / hide the floating "jump to latest" button the instant the
92
101
  // user detaches from or re-reaches the bottom.
93
102
  updateJumpToLatestButton(scroller);
@@ -125,6 +134,7 @@
125
134
  if (!el) {
126
135
  return;
127
136
  }
137
+ if (el.dataset) el.dataset.logAutoFollow = logAutoFollow ? 'true' : 'false';
128
138
  const button = ensureJumpToLatestButton();
129
139
  if (!button) {
130
140
  return;
@@ -157,8 +167,16 @@
157
167
  return getPanel()?.querySelector('[data-log]') || getPanel()?.querySelector('[data-main]');
158
168
  }
159
169
 
160
- function markUserScrollIntent() {
170
+ function markUserScrollIntent(event) {
171
+ const scroller = getLogScrollContainer();
172
+ if (!scroller || (event?.type === 'pointerdown' && event.target !== scroller)) return;
173
+ if (event?.target?.closest?.('input, textarea, select, [contenteditable="true"]')) return;
161
174
  userScrollIntentUntil = Date.now() + 1200;
175
+ userScrollExtent = `${scroller.scrollHeight}:${scroller.clientHeight}`;
176
+ if ((event?.type === 'wheel' && event.deltaY < 0) || ['ArrowUp', 'PageUp', 'Home'].includes(event?.key)) {
177
+ logAutoFollow = false;
178
+ scrollLogPendingForce = false;
179
+ }
162
180
  }
163
181
 
164
182
  function isLogNearBottom(log) {
@@ -181,7 +199,7 @@
181
199
  userScrollIntentUntil = 0;
182
200
  unreadSinceDetach = 0;
183
201
  scrollLogPendingForce = true;
184
- } else if (!(logAutoFollow || isLogNearBottom(scroller))) {
202
+ } else if (!logAutoFollow) {
185
203
  // The user has scrolled up: do not yank them. Just keep the jump button
186
204
  // state current.
187
205
  updateJumpToLatestButton(scroller);
@@ -195,7 +213,7 @@
195
213
  }
196
214
  // Re-check intent AT PAINT TIME: a user who flicked up between schedule
197
215
  // and paint must not be snapped back down (closes the one-frame fight).
198
- if (scrollLogPendingForce || logAutoFollow || isLogNearBottom(el)) {
216
+ if (scrollLogPendingForce || logAutoFollow) {
199
217
  setLogScrollPosition(el);
200
218
  }
201
219
  scrollLogPendingForce = false;
@@ -284,11 +302,13 @@
284
302
  }
285
303
  return tr('processed', { elapsed });
286
304
  }
287
- function renderRunHistory() {
305
+ function renderRunHistory(options = {}) {
288
306
  const log = getPanel()?.querySelector('[data-log]');
289
307
  if (!log) {
290
308
  return;
291
309
  }
310
+ const reading = options.preserveScroll === true && !logAutoFollow
311
+ ? scrollLayout?.snapshot() || { scrollTop: log.scrollTop } : null;
292
312
  log.replaceChildren();
293
313
  if (!getState().runs?.length) {
294
314
  const empty = document.createElement('div');
@@ -317,7 +337,8 @@
317
337
  for (const run of runs) {
318
338
  log.append(renderRunCard(run));
319
339
  }
320
- scrollLogToBottom({ force: true });
340
+ if (reading) { log.scrollTop = reading.scrollTop; scrollLayout?.restore(reading); updateJumpToLatestButton(log); }
341
+ else scrollLogToBottom({ force: true });
321
342
  }
322
343
 
323
344
  function renderRunCard(run) {
@@ -637,7 +658,7 @@
637
658
  if (row.key) dt.dataset.metaKey = row.key;
638
659
  const dd = document.createElement('dd');
639
660
  dd.className = 'run-final-answer__meta-value';
640
- dd.textContent = row.value;
661
+ dd.textContent = failureNotice?.formatMetaValue(report, row) ?? row.value;
641
662
  metaBlock.append(dt, dd);
642
663
  }
643
664
  if (metaBlock.children.length) {
@@ -714,6 +735,7 @@
714
735
  report.append(main);
715
736
  }
716
737
 
738
+ failureNotice?.append(report, event, run);
717
739
  appendCompletionMetaBlock(report, structured.meta);
718
740
  appendCompileFix(report);
719
741
  appendRejectedRedo(report);
@@ -735,6 +757,7 @@
735
757
  report.append(body);
736
758
  appendCompileFix(report);
737
759
  appendRejectedRedo(report);
760
+ failureNotice?.append(report, event, run);
738
761
  appendCompletionMetaBlock(report, split.meta);
739
762
  appendRecoveryActionForFailure(report, event, run);
740
763
  return report;
@@ -793,6 +816,9 @@
793
816
  }
794
817
 
795
818
  function appendRecoveryActionForFailure(report, event, run) {
819
+ const recovery = failureNotice?.prepareRecovery(report, { openFile: openProjectFileForFailure });
820
+ if (recovery?.handled) return;
821
+ report = recovery?.target || report;
796
822
  const failureCode = event?.failure?.code || '';
797
823
  if (RETRYABLE_FAILURE_CODES.has(failureCode) && typeof refillComposerForRetry === 'function') {
798
824
  const retry = buildRecoveryButton(failureCode,
@@ -95,18 +95,18 @@
95
95
  </details>
96
96
  <details class="codex-set-group" data-set-group="experimental">
97
97
  <summary class="codex-set-group-head">
98
- <span class="codex-set-group-title">${codexSetIcon('flask')}<span data-i18n="experimentalTitle">Experimental</span></span>
98
+ <span class="codex-set-group-title">${codexSetIcon('database')}<span data-i18n="otSettingsTitle">Project cache</span></span>
99
99
  <span class="codex-set-saved" data-set-saved hidden>✓ <span data-i18n="settingsSaved">Saved</span></span>
100
100
  </summary>
101
101
  <div class="codex-set-card">
102
102
  <label class="codex-project-settings-row codex-project-settings-row--switch">
103
103
  <span class="codex-project-settings-row-label">
104
- <span data-i18n="experimentalOtMenuTitle">Experimental OT Mirror</span>
105
- <small class="codex-set-row-sub" data-i18n="experimentalOtMenuSubtitle">Experimental — speeds up reading the file you are editing; Codex falls back to normal reading whenever unsure.</small>
104
+ <span data-i18n="experimentalOtMenuTitle">OT Mirror</span>
105
+ <small class="codex-set-row-sub" data-i18n="experimentalOtMenuSubtitle">Warms recent edits to the active text file locally. File scope and freshness are checked before reuse.</small>
106
106
  </span>
107
107
  <input type="checkbox" class="codex-switch" data-experimental-ot>
108
108
  </label>
109
- <p class="codex-set-row-help" data-experimental-ot-menu-status></p>
109
+ <p class="codex-set-row-help" data-experimental-ot-menu-status aria-live="polite"></p>
110
110
  </div>
111
111
  </details>
112
112
  </div>
@@ -159,17 +159,25 @@
159
159
  <button type="button" class="codex-set-btn" data-provider-settings-open data-i18n="providerSettingsConfigure">Configure</button>
160
160
  </div>
161
161
  </details>
162
+ <details class="codex-set-group" data-set-group="context-loading" open>
163
+ <summary class="codex-set-group-head">
164
+ <span class="codex-set-group-title">${codexSetIcon('database')}<span data-i18n="contextLoadingTitle">Context loading</span></span>
165
+ <span class="codex-set-saved" data-set-saved hidden>✓ <span data-i18n="settingsSaved">Saved</span></span>
166
+ </summary>
167
+ <div class="codex-set-card">
168
+ <label class="codex-project-settings-row codex-project-settings-row--switch">
169
+ <span class="codex-project-settings-row-label" data-i18n="preloadProjectContextTitle">Preload project context</span>
170
+ <input type="checkbox" class="codex-switch" data-preload-project-context>
171
+ </label>
172
+ <p class="codex-set-row-help" data-i18n="preloadProjectContextDescription">Fetch the exact project file list in the background after an Overleaf project becomes idle.</p>
173
+ </div>
174
+ </details>
162
175
  <details class="codex-set-group" data-set-group="updates" open>
163
176
  <summary class="codex-set-group-head">
164
177
  <span class="codex-set-group-title">${codexSetIcon('software')}<span data-i18n="softwareUpdatesTitle">Software updates</span></span>
165
178
  </summary>
166
179
  <div class="codex-set-card" data-update-settings>
167
180
  <div class="codex-set-row">
168
- <label class="codex-project-settings-row codex-project-settings-row--switch">
169
- <span class="codex-project-settings-row-label" data-i18n="preloadProjectContextTitle">Preload project context</span>
170
- <input type="checkbox" class="codex-switch" data-preload-project-context>
171
- </label>
172
- <p class="codex-set-row-help" data-i18n="preloadProjectContextDescription">Fetch the exact project file list in the background after an Overleaf project becomes idle.</p>
173
181
  <p class="codex-set-row-help" data-update-settings-summary data-i18n="softwareUpdatesHelp">Automatically checks signed stable releases for the Extension and Native Host.</p>
174
182
  <p class="codex-set-note" data-update-settings-state aria-live="polite" hidden></p>
175
183
  <button type="button" class="codex-set-btn" data-check-updates data-i18n="checkForUpdates">Check for updates</button>
@@ -235,8 +243,11 @@
235
243
  instance.callbacks.onHistoryOpen?.();
236
244
  }
237
245
  });
246
+ container.querySelector('[data-storage-card]')?.addEventListener('toggle', event => {
247
+ if (event.target.open) instance.callbacks.onStorageOpen?.();
248
+ });
238
249
  container.querySelector('[data-history-filter]')?.addEventListener('input', () => instance.callbacks.onHistoryFilter?.());
239
- // Experimental OT mirror: a single visible switch. The click is
250
+ // OT mirror: a single visible switch; legacy storage/DOM keys are retained. The click is
240
251
  // intercepted (the runtime's handler preventDefaults, runs the enable
241
252
  // confirmation, then sets checked + drives the change flow itself) so the
242
253
  // confirm-before-enable contract survives the control unification.
@@ -252,7 +263,12 @@
252
263
  const element = container.querySelector(selector);
253
264
  element?.addEventListener?.('change', event => {
254
265
  Promise.resolve(instance.callbacks.onInputChange?.(event))
255
- .then(() => flashSaved(instance, event)).catch(() => {});
266
+ .then(() => {
267
+ flashSaved(instance, event);
268
+ if (selector === '[data-language-select]' && container.querySelector('[data-storage-card]')?.open) {
269
+ instance.callbacks.onStorageOpen?.();
270
+ }
271
+ }).catch(() => {});
256
272
  });
257
273
  element?.addEventListener?.('input', event => {
258
274
  Promise.resolve(instance.callbacks.onInputChange?.(event)).catch(() => {});
@@ -267,6 +283,7 @@
267
283
  setupGroupPersistence(instance);
268
284
 
269
285
  return {
286
+ get container() { return instance.container; },
270
287
  show: () => show(instance),
271
288
  hide: () => hide(instance),
272
289
  loadState: state => loadState(instance, state),
@@ -90,7 +90,8 @@
90
90
  install: 'codex-overleaf/consent-update-install',
91
91
  retry: 'codex-overleaf/consent-update-check',
92
92
  later: 'codex-overleaf/consent-update-later',
93
- dismiss: 'codex-overleaf/consent-update-dismiss'
93
+ dismiss: 'codex-overleaf/consent-update-dismiss',
94
+ reload: 'codex-overleaf/consent-update-reload'
94
95
  }[action]);
95
96
  } catch (error) {
96
97
  currentView = await reconcileAfterActionError(error, {
@@ -212,7 +213,10 @@
212
213
  const stateName = currentView?.state?.state || 'idle';
213
214
  let requestedAction = 'check';
214
215
  try {
215
- if (stateName === 'update_available') {
216
+ if (['reload_required', 'reload_tabs_required'].includes(stateName)) {
217
+ requestedAction = 'reload';
218
+ currentView = await request('codex-overleaf/consent-update-reload');
219
+ } else if (stateName === 'update_available') {
216
220
  requestedAction = 'install';
217
221
  currentView = await request('codex-overleaf/consent-update-install');
218
222
  } else if (['failed', 'rolled_back'].includes(stateName)) {
@@ -276,7 +280,7 @@
276
280
  body.append(recovery, command);
277
281
  }
278
282
 
279
- if (!['update_available', 'failed', 'rolled_back'].includes(state.state)) {
283
+ if (!['update_available', 'failed', 'rolled_back', 'reload_required', 'reload_tabs_required'].includes(state.state)) {
280
284
  const bar = document.createElement('div');
281
285
  bar.className = 'codex-update-notice-progress' + (progress.determinate ? '' : ' is-indeterminate');
282
286
  bar.setAttribute('role', 'progressbar');
@@ -328,7 +332,11 @@
328
332
  let statusText = '';
329
333
  let buttonText = tx('Check for updates', '检查更新');
330
334
 
331
- if (stateName === 'checking') {
335
+ if (['reload_required', 'reload_tabs_required'].includes(stateName)) {
336
+ summaryText = tx(`Installed v${latestVersion}; running extension v${currentVersion}.`, `已安装 v${latestVersion};扩展当前运行 v${currentVersion}。`);
337
+ statusText = getCopy(state).detail;
338
+ buttonText = stateName === 'reload_required' ? tx('Reload extension', '重新加载扩展') : tx('Refresh Overleaf tabs', '刷新 Overleaf 标签页');
339
+ } else if (stateName === 'checking') {
332
340
  statusText = tx('Checking the latest stable release…', '正在检查最新稳定版本…');
333
341
  buttonText = tx('Checking…', '检查中…');
334
342
  } else if (stateName === 'update_available') {
@@ -355,6 +363,10 @@
355
363
 
356
364
  function visibleActions(state) {
357
365
  if (actionInFlight) return [];
366
+ if (['reload_required', 'reload_tabs_required'].includes(state)) {
367
+ return [{ id: 'reload', label: state === 'reload_required'
368
+ ? tx('Reload extension', '重新加载扩展') : tx('Refresh Overleaf tabs', '刷新 Overleaf 标签页'), primary: true }];
369
+ }
358
370
  if (state === 'update_available') {
359
371
  return [
360
372
  { id: 'later', label: tx('Later', '稍后'), primary: false },
@@ -380,6 +392,14 @@
380
392
  const target = state.latestVersion ? 'v' + state.latestVersion : '';
381
393
  const blocker = blockersCopy(state.blockers?.length ? state.blockers : [state.blocker]);
382
394
  return {
395
+ reload_required: {
396
+ eyebrow: tx('Installed, awaiting reload', '已安装,等待加载'), title: target,
397
+ detail: state.message || tx('The new files are installed. Reload the extension when Overleaf is saved and idle.', '新版文件已安装,请在 Overleaf 保存并空闲后重新加载扩展。')
398
+ },
399
+ reload_tabs_required: {
400
+ eyebrow: tx('Updated runtime is ready', '新版运行组件已就绪'), title: target,
401
+ detail: state.message || tx('Refresh the saved, idle Overleaf tabs to load the updated panel.', '刷新已保存且空闲的 Overleaf 标签页以加载新版面板。')
402
+ },
383
403
  update_available: {
384
404
  eyebrow: tx('Update available', '发现新版本'),
385
405
  title: target,