codex-overleaf-link 2.3.5 → 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 (57) hide show
  1. package/README.md +63 -31
  2. package/README.zh-CN.md +543 -0
  3. package/extension/bootstrap/manifest.template.json +1 -1
  4. package/extension/src/background.js +7 -0
  5. package/extension/src/backgroundUpdateCoordinator.js +88 -4
  6. package/extension/src/content/activeTurnControl.js +1 -0
  7. package/extension/src/content/assetTransferBroker.js +2 -0
  8. package/extension/src/content/contentRuntime.js +77 -57
  9. package/extension/src/content/contextTray.js +4 -0
  10. package/extension/src/content/diagnosticsController.js +17 -3
  11. package/extension/src/content/diagnosticsPanel.js +129 -5
  12. package/extension/src/content/generated/content.bundle.js +1422 -348
  13. package/extension/src/content/generated/content.bundle.meta.json +7 -3
  14. package/extension/src/content/legacyGlobalRegistry.js +3 -0
  15. package/extension/src/content/modelPicker.js +21 -9
  16. package/extension/src/content/modelPickerSupport.js +38 -1
  17. package/extension/src/content/nativeCompatibilityController.js +4 -1
  18. package/extension/src/content/otWarmMirror.js +126 -92
  19. package/extension/src/content/otWarmMirrorController.js +122 -1
  20. package/extension/src/content/pageBridgeClient.js +2 -0
  21. package/extension/src/content/panelMaintenance.js +26 -15
  22. package/extension/src/content/providerSettingsCoordinator.js +1 -0
  23. package/extension/src/content/runFailureNotice.js +104 -0
  24. package/extension/src/content/runResultActions.js +16 -1
  25. package/extension/src/content/runScrollLayout.js +124 -0
  26. package/extension/src/content/runTimelineView.js +37 -11
  27. package/extension/src/content/sessionForkController.js +20 -11
  28. package/extension/src/content/settingsPanel.js +28 -11
  29. package/extension/src/content/trackedChangeCaptureController.js +153 -0
  30. package/extension/src/content/updateNotice.js +24 -4
  31. package/extension/src/content/writebackOrchestrator.js +31 -0
  32. package/extension/src/page/binaryAssetUploader.js +101 -29
  33. package/extension/src/page/overleafRealtimeObserver.js +120 -25
  34. package/extension/src/page/textFileCreator.js +493 -0
  35. package/extension/src/page/trackedChangeCapture.js +342 -0
  36. package/extension/src/page/trackedChangesLifecycle.js +145 -149
  37. package/extension/src/page/treeOperations.js +30 -2
  38. package/extension/src/page/writebackRouter.js +94 -48
  39. package/extension/src/pageBridge.js +62 -25
  40. package/extension/src/shared/compatibility.js +1 -1
  41. package/extension/src/shared/i18n.js +77 -37
  42. package/extension/src/shared/managedUpdateProjection.js +1 -1
  43. package/extension/src/shared/otText.js +0 -1
  44. package/extension/src/shared/pageRpcContract.js +2 -1
  45. package/extension/src/shared/sessionState.js +23 -23
  46. package/extension/src/shared/sessionTitle.js +120 -0
  47. package/extension/src/shared/storageDb.js +10 -12
  48. package/extension/src/shared/storageRunActions.js +29 -1
  49. package/extension/src/shared/updateRuntimeIdentity.js +32 -0
  50. package/extension/src/shared/writebackSettlement.js +19 -5
  51. package/extension/styles/panel.css +128 -24
  52. package/native-host/src/mirrorWorkspace.js +20 -1
  53. package/native-host/src/releaseProxy.js +177 -0
  54. package/native-host/src/releaseTransport.js +154 -0
  55. package/native-host/src/taskRunnerRuntime.js +25 -0
  56. package/native-host/src/updateManager.js +40 -39
  57. package/package.json +5 -1
@@ -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())
@@ -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
+ });
@@ -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 });
@@ -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,
@@ -906,7 +932,7 @@
906
932
  return;
907
933
  }
908
934
 
909
- const projection = projectRunSettlement(run);
935
+ const projection = RunResultActions.projectUndoAvailability(run, projectRunSettlement);
910
936
  if (!projection.canUndo && run.undoStatus !== 'applied') {
911
937
  button.hidden = true;
912
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
  }
@@ -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),