codex-overleaf-link 2.3.3 → 2.3.5

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.
@@ -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": "4b5c2f7a7653947ac71ae42a3301c7547f789c2ca33e25f99f6781f31d1147be",
6
- "outputDigest": "4e32bcf7d8be72541bb39508e38335ad0e32e41844afa45d7f0d34c9481a1cec",
7
- "byteLength": 2313648,
5
+ "sourceDigest": "7ed22d5c82b20220316824547e1dfc359e45dfcdf1f1bca2fb7280195a05361c",
6
+ "outputDigest": "50436e35fe942b3fd8540a6267fba79c41a53c216b1ffc42995e4ed026771de4",
7
+ "byteLength": 2337940,
8
8
  "inputs": [
9
9
  "extension/entries/content-entry.mjs",
10
10
  "extension/src/content/activeTurnControl.js",
@@ -17,6 +17,7 @@
17
17
  "extension/src/content/diagnosticsController.js",
18
18
  "extension/src/content/diagnosticsPanel.js",
19
19
  "extension/src/content/diffReviewPanel.js",
20
+ "extension/src/content/globalPreferencesController.js",
20
21
  "extension/src/content/legacyGlobalRegistry.js",
21
22
  "extension/src/content/localSkillsPanel.js",
22
23
  "extension/src/content/markdownDomRenderer.js",
@@ -68,6 +69,7 @@
68
69
  "extension/src/shared/compatibility.js",
69
70
  "extension/src/shared/compileAdapter.js",
70
71
  "extension/src/shared/failureReasons.js",
72
+ "extension/src/shared/globalPreferences.js",
71
73
  "extension/src/shared/governanceRules.js",
72
74
  "extension/src/shared/i18n.js",
73
75
  "extension/src/shared/lineReferences.js",
@@ -0,0 +1,125 @@
1
+ (function (root, factory) {
2
+ if (typeof module === 'object' && module.exports) module.exports = factory(require('../shared/globalPreferences'));
3
+ else root.CodexOverleafModuleRegistry.define('GlobalPreferencesController', ['GlobalPreferences'], factory);
4
+ })(typeof globalThis !== 'undefined' ? globalThis : this, function (Preferences) {
5
+ 'use strict';
6
+
7
+ const CONTROLS = Object.freeze({ theme: '[data-theme-select]', locale: '[data-language-select]',
8
+ preloadProjectContext: '[data-preload-project-context]', loadCodexLocalSkills: '[data-load-codex-local-skills]',
9
+ loadCodexOverleafSkills: '[data-load-codex-overleaf-skills]' });
10
+
11
+ function create(options) {
12
+ const chromeApi = options.chromeApi;
13
+ const fallback = Preferences.pick(options.getState?.() || {});
14
+ let snapshot = null;
15
+ let loading = null;
16
+ let subscribed = false;
17
+ let disposed = false;
18
+ let lastError = null;
19
+ let tail = Promise.resolve();
20
+ const pending = [];
21
+
22
+ function view() {
23
+ return pending.reduce((values, operation) => Preferences.applyPatch(values, operation.patch), snapshot?.values || fallback);
24
+ }
25
+
26
+ function render(force = false) {
27
+ if (disposed) return;
28
+ const previous = options.getState?.() || {};
29
+ const values = view();
30
+ const changed = Preferences.changedKeys(Preferences.pick(previous), values);
31
+ options.setState?.({ ...previous, ...values });
32
+ const panel = options.getPanel?.();
33
+ if (!panel) return;
34
+ for (const [field, selector] of Object.entries(CONTROLS)) {
35
+ const control = panel.querySelector(selector);
36
+ if (!control) continue;
37
+ if (typeof values[field] === 'boolean') control.checked = values[field];
38
+ else if (control.value !== values[field]) control.value = values[field];
39
+ }
40
+ if (force || changed.includes('theme')) options.applyTheme?.(values.theme);
41
+ if (changed.includes('locale')) options.applyLocale?.();
42
+ if (force || changed.includes('preloadProjectContext')) options.onPreloadChange?.(values.preloadProjectContext);
43
+ if (changed.some(key => key === 'codexOverleafSkillEnabled' || key.startsWith('loadCodex'))) options.onSkillsChange?.();
44
+ }
45
+
46
+ function accept(value) {
47
+ const next = Preferences.validateSnapshot(value);
48
+ lastError = null;
49
+ if (!snapshot || next.revision > snapshot.revision) {
50
+ snapshot = next;
51
+ render();
52
+ }
53
+ return snapshot;
54
+ }
55
+
56
+ async function request(type, patch) {
57
+ const response = await chromeApi.runtime.sendMessage({ type, ...(patch ? { patch } : {}) });
58
+ if (!response?.ok) throw new Error(response?.error?.message || 'Global preferences are unavailable.');
59
+ return response.result;
60
+ }
61
+
62
+ function onStorageChange(changes, area) {
63
+ if (disposed || area !== 'local' || !changes[Preferences.STORAGE_KEY]?.newValue) return;
64
+ try { accept(changes[Preferences.STORAGE_KEY].newValue); } catch (error) { reportError(error); }
65
+ }
66
+
67
+ function reportError(error) {
68
+ lastError = error;
69
+ options.onError?.(error);
70
+ }
71
+
72
+ async function initialize() {
73
+ if (!subscribed) {
74
+ chromeApi.storage.onChanged.addListener(onStorageChange);
75
+ subscribed = true;
76
+ }
77
+ if (snapshot) return snapshot;
78
+ if (!loading) {
79
+ loading = request(Preferences.READ_MESSAGE).then(accept).catch(error => {
80
+ reportError(error);
81
+ return null;
82
+ }).finally(() => { loading = null; });
83
+ }
84
+ return loading;
85
+ }
86
+
87
+ function update(input) {
88
+ const patch = Preferences.validatePatch(input);
89
+ if (!Preferences.changedKeys(view(), Preferences.applyPatch(view(), patch)).length) return pending.length ? tail : Promise.resolve(snapshot);
90
+ const operation = { patch };
91
+ pending.push(operation);
92
+ render();
93
+ const result = tail.catch(() => {}).then(() => request(Preferences.PATCH_MESSAGE, patch)).then(Preferences.validateSnapshot).then(value => {
94
+ accept(value);
95
+ pending.splice(pending.indexOf(operation), 1);
96
+ render();
97
+ return snapshot;
98
+ }, error => {
99
+ pending.splice(pending.indexOf(operation), 1);
100
+ render();
101
+ reportError(error);
102
+ throw error;
103
+ });
104
+ tail = result;
105
+ return result;
106
+ }
107
+
108
+ function handleInput(event) {
109
+ const field = Object.keys(CONTROLS).find(key => event?.target?.matches?.(CONTROLS[key]));
110
+ if (!field) return null;
111
+ return update({ [field]: ['theme', 'locale'].includes(field) ? event.target.value : Boolean(event.target.checked) });
112
+ }
113
+
114
+ return Object.freeze({ initialize, update, handleInput,
115
+ overlay: state => snapshot || pending.length ? { ...state, ...view() } : state,
116
+ refreshView: () => { if (snapshot) render(true); if (lastError) options.onError?.(lastError); },
117
+ destroy() {
118
+ disposed = true;
119
+ if (subscribed) chromeApi.storage.onChanged.removeListener(onStorageChange);
120
+ }
121
+ });
122
+ }
123
+
124
+ return Object.freeze({ create });
125
+ });
@@ -27,6 +27,8 @@
27
27
  'DiffReviewPanel',
28
28
  'FailureReasons',
29
29
  'GovernanceRules',
30
+ 'GlobalPreferences',
31
+ 'GlobalPreferencesController',
30
32
  'I18n',
31
33
  'LineReferences',
32
34
  'LocalSkillsPanel',
@@ -319,7 +319,7 @@
319
319
  if (!efforts.length) {
320
320
  efforts = presentation === 'none' ? ['none'] : ['low', 'medium', 'high', 'xhigh'];
321
321
  }
322
- const current = reasoningSelect.value || getState()?.reasoningEffort || '';
322
+ const current = getState()?.reasoningEffort || reasoningSelect.value || '';
323
323
  const fallback = efforts.includes(model?.defaultReasoningEffort)
324
324
  ? model.defaultReasoningEffort
325
325
  : efforts.includes('high') ? 'high' : efforts[0];
@@ -29,6 +29,7 @@
29
29
  setState,
30
30
  getCurrentRunView,
31
31
  getSettingsPanelInstance,
32
+ prepareRetryReplacement,
32
33
  StorageDb
33
34
  } = deps;
34
35
 
@@ -218,6 +219,7 @@
218
219
  showPluginToast(tx('Nothing to refill: the original task text is unavailable.', '无法回填:原任务文本不可用。'));
219
220
  return;
220
221
  }
222
+ prepareRetryReplacement?.(run || null);
221
223
  input.value = task;
222
224
  getState().task = task;
223
225
  autosizeTaskTextarea();
@@ -4,11 +4,14 @@
4
4
  const ROOT_CLASS = 'codex-overleaf-panel-mounted';
5
5
  const RESIZE_CLASS = 'codex-overleaf-panel-resizing';
6
6
  const COMPACT_CLASS = 'codex-overleaf-panel-compact';
7
+ const LAUNCHER_VISIBILITY_STORAGE_KEY = 'codex-overleaf:launcher-visible';
7
8
 
8
9
  function codexIcon(name) {
9
10
  const icons = {
10
11
  refresh: '<path d="M4.2 5.3A5 5 0 1 1 3.6 10"/><path d="M4.2 2.2v3.1H1.1"/>',
11
12
  plus: '<path d="M8 3.2v9.6"/><path d="M3.2 8h9.6"/>',
13
+ panel: '<rect x="2.3" y="2.3" width="11.4" height="11.4" rx="2"/><path d="M9.2 2.5v11"/><path d="M5.1 6.1h1.5M5.1 8h1.5M5.1 9.9h1.5"/>',
14
+ collapse: '<path d="m6.1 3.3 4.7 4.7-4.7 4.7"/>',
12
15
  settings: '<path d="M3.4 5.2h5.9"/><path d="M11.7 5.2h.9"/><path d="M10.1 3.6v3.2"/><path d="M3.4 10.8h1"/><path d="M6.8 10.8h5.8"/><path d="M5.9 9.2v3.2"/>'
13
16
  };
14
17
  const safeName = Object.prototype.hasOwnProperty.call(icons, name) ? name : 'settings';
@@ -19,9 +22,11 @@
19
22
  const doc = options.document || document;
20
23
  const container = options.container || doc.documentElement;
21
24
  const panelEl = doc.createElement('aside');
25
+ const launcherEl = doc.createElement('button');
22
26
  const callbacks = options.callbacks || {};
23
27
  const instance = {
24
28
  panelEl,
29
+ launcherEl,
25
30
  callbacks,
26
31
  defaultWidth: options.defaultWidth || 380,
27
32
  minWidth: options.minWidth || 340,
@@ -30,9 +35,18 @@
30
35
  lastDesktopWidth: options.initialWidth || options.defaultWidth || 380,
31
36
  listeners: [],
32
37
  document: doc,
33
- window: options.window || window
38
+ window: options.window || window,
39
+ chromeApi: options.chromeApi || (typeof chrome !== 'undefined' ? chrome : null),
40
+ storageChangeListener: null
34
41
  };
35
42
 
43
+ launcherEl.id = 'codex-overleaf-launcher';
44
+ launcherEl.type = 'button';
45
+ launcherEl.title = options.launcherLabel || 'Show or hide Codex panel';
46
+ launcherEl.setAttribute('aria-label', launcherEl.title);
47
+ launcherEl.setAttribute('aria-expanded', 'false');
48
+ launcherEl.innerHTML = codexIcon('panel');
49
+
36
50
  panelEl.id = options.panelId || 'codex-overleaf-panel';
37
51
  panelEl.dataset.view = 'session';
38
52
  panelEl.innerHTML = `
@@ -44,6 +58,7 @@
44
58
  <div data-diagnostics-slot></div>
45
59
  <button type="button" data-new-session title="New Session" aria-label="New Session">${codexIcon('plus')}</button>
46
60
  <button type="button" data-custom-instructions-settings title="Project Settings" aria-label="Project Settings" aria-expanded="false">${codexIcon('settings')}</button>
61
+ <button type="button" data-close-panel title="Close Codex panel" aria-label="Close Codex panel">${codexIcon('collapse')}</button>
47
62
  </div>
48
63
  </div>
49
64
  <div data-settings-slot></div>
@@ -59,6 +74,7 @@
59
74
  `;
60
75
 
61
76
  container.append(panelEl);
77
+ container.append(launcherEl);
62
78
  doc.documentElement.classList.add(ROOT_CLASS);
63
79
 
64
80
  instance.headerEl = panelEl.querySelector('[data-panel-header]');
@@ -73,6 +89,8 @@
73
89
  bind(instance, panelEl.querySelector('[data-refresh]'), 'click', () => callbacks.onRefresh?.());
74
90
  bind(instance, panelEl.querySelector('[data-new-session]'), 'click', () => callbacks.onNewSession?.());
75
91
  bind(instance, panelEl.querySelector('[data-custom-instructions-settings]'), 'click', () => callbacks.onSettingsClick?.());
92
+ bind(instance, panelEl.querySelector('[data-close-panel]'), 'click', () => callbacks.onClosePanel?.());
93
+ bind(instance, launcherEl, 'click', () => callbacks.onLauncherToggle?.());
76
94
  bind(instance, panelEl.querySelector('[data-panel-resize-handle]'), 'pointerdown', event => startResize(instance, event));
77
95
  bind(instance, panelEl.querySelector('[data-panel-resize-handle]'), 'dblclick', event => {
78
96
  event?.preventDefault?.();
@@ -87,9 +105,11 @@
87
105
  });
88
106
 
89
107
  setWidth(instance, options.initialWidth || instance.defaultWidth, { notify: false });
108
+ initializeLauncherVisibility(instance);
90
109
 
91
110
  return {
92
111
  panelEl,
112
+ launcherEl,
93
113
  headerEl: instance.headerEl,
94
114
  bodyEl: instance.bodyEl,
95
115
  diagnosticsSlot: instance.diagnosticsSlot,
@@ -198,11 +218,64 @@
198
218
  function setVisible(panelEl, visible) {
199
219
  panelEl?.classList?.toggle('is-open', Boolean(visible));
200
220
  document.documentElement.classList.toggle(ROOT_CLASS, Boolean(visible));
221
+ const launcherEl = panelEl?.ownerDocument?.getElementById?.('codex-overleaf-launcher');
222
+ launcherEl?.classList?.toggle('is-panel-open', Boolean(visible));
223
+ launcherEl?.setAttribute?.('aria-expanded', visible ? 'true' : 'false');
224
+ if (launcherEl) {
225
+ launcherEl.hidden = Boolean(visible) || launcherEl.dataset.enabled === 'false';
226
+ launcherEl.setAttribute('aria-hidden', launcherEl.hidden ? 'true' : 'false');
227
+ }
201
228
  if (!visible) {
202
229
  document.documentElement.classList.remove(RESIZE_CLASS, COMPACT_CLASS);
203
230
  }
204
231
  }
205
232
 
233
+ function initializeLauncherVisibility(instance) {
234
+ const storage = instance.chromeApi?.storage;
235
+ if (!storage?.local?.get) {
236
+ setLauncherVisible(instance, true, { persist: false });
237
+ return;
238
+ }
239
+ Promise.resolve(storage.local.get({ [LAUNCHER_VISIBILITY_STORAGE_KEY]: true }))
240
+ .then(values => applyLauncherVisibility(instance, values?.[LAUNCHER_VISIBILITY_STORAGE_KEY]))
241
+ .catch(() => applyLauncherVisibility(instance, true));
242
+ if (storage.onChanged?.addListener) {
243
+ instance.storageChangeListener = (changes, areaName) => {
244
+ if (areaName === 'local' && changes?.[LAUNCHER_VISIBILITY_STORAGE_KEY]) {
245
+ applyLauncherVisibility(instance, changes[LAUNCHER_VISIBILITY_STORAGE_KEY].newValue);
246
+ }
247
+ };
248
+ storage.onChanged.addListener(instance.storageChangeListener);
249
+ }
250
+ }
251
+
252
+ function applyLauncherVisibility(instance, value) {
253
+ const visible = value !== false;
254
+ setLauncherVisible(instance, visible, { persist: false });
255
+ instance.callbacks.onLauncherVisibilityChange?.(visible);
256
+ }
257
+
258
+ function setLauncherVisible(target, visible, options = {}) {
259
+ const instance = target?._instance || target;
260
+ const launcherEl = instance?.launcherEl;
261
+ if (!launcherEl) return false;
262
+ const nextVisible = Boolean(visible);
263
+ launcherEl.dataset.enabled = nextVisible ? 'true' : 'false';
264
+ launcherEl.hidden = !nextVisible || instance.panelEl?.classList?.contains('is-open') === true;
265
+ launcherEl.setAttribute('aria-hidden', launcherEl.hidden ? 'true' : 'false');
266
+ if (options.persist === true) {
267
+ Promise.resolve(instance.chromeApi?.storage?.local?.set?.({
268
+ [LAUNCHER_VISIBILITY_STORAGE_KEY]: nextVisible
269
+ })).catch(() => {});
270
+ }
271
+ return nextVisible;
272
+ }
273
+
274
+ function isLauncherVisible(target) {
275
+ const instance = target?._instance || target;
276
+ return Boolean(instance?.launcherEl && instance.launcherEl.dataset.enabled !== 'false');
277
+ }
278
+
206
279
  function setBadge(headerEl, badge = {}) {
207
280
  const actions = headerEl?.querySelector?.('.codex-vscode-head-actions');
208
281
  if (!actions) {
@@ -254,7 +327,8 @@
254
327
 
255
328
  return new Promise(resolve => {
256
329
  let settled = false;
257
- const overlay = doc.createElement('div');
330
+ const returnFocus = doc.activeElement;
331
+ const overlay = doc.createElement('dialog');
258
332
  overlay.className = 'codex-plugin-confirm';
259
333
  overlay.setAttribute('data-plugin-confirm', 'true');
260
334
  overlay.setAttribute('role', 'dialog');
@@ -298,13 +372,16 @@
298
372
  card.append(head, body, actions);
299
373
  overlay.append(card);
300
374
  panel.append(overlay);
375
+ overlay.showModal?.();
301
376
 
302
377
  const cleanup = value => {
303
378
  if (settled) return;
304
379
  settled = true;
305
380
  activeResolve = null;
306
381
  doc.removeEventListener('keydown', onKeydown, true);
382
+ overlay.close?.();
307
383
  overlay.remove();
384
+ returnFocus?.focus?.();
308
385
  resolve(value);
309
386
  };
310
387
  activeResolve = cleanup;
@@ -325,6 +402,7 @@
325
402
  overlay.addEventListener('click', event => {
326
403
  if (event.target === overlay) cleanup(false);
327
404
  });
405
+ overlay.addEventListener('cancel', event => { event.preventDefault(); cleanup(false); });
328
406
  cancel.addEventListener('click', () => cleanup(false));
329
407
  confirm.addEventListener('click', () => cleanup(true));
330
408
  doc.addEventListener('keydown', onKeydown, true);
@@ -340,6 +418,10 @@
340
418
  target.removeEventListener?.(type, listener, options);
341
419
  }
342
420
  instance.panelEl?.remove?.();
421
+ instance.launcherEl?.remove?.();
422
+ if (instance.storageChangeListener) {
423
+ instance.chromeApi?.storage?.onChanged?.removeListener?.(instance.storageChangeListener);
424
+ }
343
425
  instance.document.documentElement.classList.remove(ROOT_CLASS, RESIZE_CLASS, COMPACT_CLASS);
344
426
  }
345
427
 
@@ -347,6 +429,8 @@
347
429
  create,
348
430
  createConfirmController,
349
431
  setVisible,
432
+ setLauncherVisible,
433
+ isLauncherVisible,
350
434
  setBadge,
351
435
  setWidth
352
436
  };
@@ -293,6 +293,7 @@
293
293
  });
294
294
  saveStateSoon();
295
295
  renderLocalSkillList();
296
+ deps.updateGlobalPreferences?.({ codexOverleafSkillEnabled: { [skillId]: Boolean(enabled) } })?.catch(() => {});
296
297
  }
297
298
 
298
299
  function readSkillLoadingSettingsFromSettings() {
@@ -14,6 +14,7 @@
14
14
  getSettingsPanelInstance: options.getSettingsPanelInstance || (() => null),
15
15
  getSelectedModel: options.getSelectedModel || (() => ''),
16
16
  getSelectedProviderId: options.getSelectedProviderId || (() => 'builtin'),
17
+ hasCurrentProject: options.hasCurrentProject || (() => true),
17
18
  setSelectedProviderId: options.setSelectedProviderId || (() => {}),
18
19
  confirmProviderSwitch: options.confirmProviderSwitch || (() => true),
19
20
  clearSelectedModel: options.clearSelectedModel || (() => {}),
@@ -43,6 +44,7 @@
43
44
  tx: instance.tx,
44
45
  ProviderProfiles: Profiles,
45
46
  callbacks: {
47
+ hasCurrentProject: instance.hasCurrentProject,
46
48
  getCurrentProjectProviderId: () => instance.getSelectedProviderId() || 'builtin',
47
49
  onTest: context => testProvider(instance, context),
48
50
  onCancelTest: () => cancelProviderTest(instance),
@@ -208,6 +210,7 @@
208
210
  }
209
211
 
210
212
  async function prepareProviderActivation(instance, providerId) {
213
+ requireCurrentProject(instance);
211
214
  const previousProviderId = instance.getSelectedProviderId() || 'builtin';
212
215
  const provider = instance.Profiles.getProviderById(instance.catalog, providerId);
213
216
  if (providerId !== previousProviderId) {
@@ -301,6 +304,7 @@
301
304
  async function saveProvider(instance, context, action = {}) {
302
305
  instance.dialog.setBusy('saving', instance.tx('Saving provider…', '正在保存模型服务…'));
303
306
  try {
307
+ if (action.activate === true) requireCurrentProject(instance);
304
308
  const result = await request(instance, 'codex.providers.upsert', {
305
309
  profileId: context.profileId,
306
310
  expectedRevision: context.expectedRevision,
@@ -495,6 +499,12 @@
495
499
  }
496
500
 
497
501
  function updateSettingsSummary(instance, override = {}) {
502
+ if (!instance.hasCurrentProject()) {
503
+ instance.getSettingsPanelInstance()?.setProviderSummary?.({ summary: instance.tx(
504
+ 'Shared model API profiles · open a project to select a provider',
505
+ '共享模型 API 配置 · 进入项目后选择使用的服务'), tone: 'info' });
506
+ return;
507
+ }
498
508
  const selectedProviderId = instance.getSelectedProviderId() || 'builtin';
499
509
  const catalogProvider = instance.Profiles.getProviderById(
500
510
  instance.catalog,
@@ -594,6 +604,11 @@
594
604
  });
595
605
  }
596
606
 
607
+ function requireCurrentProject(instance) {
608
+ if (!instance.hasCurrentProject()) throw createClientError(
609
+ 'provider_project_required', instance.tx('Open an Overleaf project before selecting its provider.', '请先进入 Overleaf 项目,再选择该项目使用的模型服务。'));
610
+ }
611
+
597
612
  function emptyCatalogChange() {
598
613
  return { activeProviderChanged: false, activeModelCatalogChanged: false, changedProviderIds: [] };
599
614
  }
@@ -47,12 +47,13 @@
47
47
  if (instance.root) {
48
48
  return instance.root;
49
49
  }
50
- const root = instance.document.createElement('div');
50
+ const root = instance.document.createElement('dialog');
51
51
  root.className = 'codex-provider-dialog-root';
52
+ root.setAttribute('aria-labelledby', 'codex-provider-dialog-title');
52
53
  root.hidden = true;
53
54
  root.innerHTML = `
54
55
  <div class="codex-provider-dialog-backdrop" data-provider-backdrop></div>
55
- <section class="codex-provider-dialog" role="dialog" aria-modal="true" aria-labelledby="codex-provider-dialog-title">
56
+ <section class="codex-provider-dialog">
56
57
  <header class="codex-provider-dialog-head">
57
58
  <div class="codex-provider-dialog-heading">
58
59
  <div class="codex-provider-dialog-titleline">
@@ -79,6 +80,7 @@
79
80
  root.addEventListener('input', event => handleInput(instance, event));
80
81
  root.addEventListener('change', event => handleInput(instance, event));
81
82
  root.addEventListener('keydown', event => handleKeydown(instance, event));
83
+ root.addEventListener('cancel', event => { event.preventDefault(); requestClose(instance); });
82
84
  root.addEventListener('mousedown', event => event.stopPropagation());
83
85
  root.addEventListener('click', event => event.stopPropagation());
84
86
  return root;
@@ -89,9 +91,12 @@
89
91
  instance.root.hidden = false;
90
92
  syncTheme(instance);
91
93
  setCatalog(instance, catalog || instance.catalog);
94
+ if (!instance.root.open) instance.root.showModal?.();
92
95
  queueMicrotask(() => {
93
- instance.root.querySelector('[data-provider-row][aria-current="true"]')?.focus?.()
94
- || instance.root.querySelector('input, button, select, textarea')?.focus?.();
96
+ if (!instance.root || instance.root.hidden) return;
97
+ const target = instance.root.querySelector('[data-provider-row][aria-current="true"]')
98
+ || instance.root.querySelector('input, button, select, textarea');
99
+ target?.focus?.();
95
100
  });
96
101
  }
97
102
 
@@ -106,6 +111,7 @@
106
111
  instance.callbacks.onCancelTest?.();
107
112
  instance.busy = '';
108
113
  }
114
+ instance.root.close?.();
109
115
  instance.root.hidden = true;
110
116
  instance.busy = '';
111
117
  instance.returnFocus?.focus?.();
@@ -159,6 +165,9 @@
159
165
  "Configure experimental third-party integrations used by this project's future Codex runs. Compatibility varies by provider and gateway.",
160
166
  '配置当前项目后续 Codex 任务使用的实验性第三方集成;兼容性取决于具体模型服务与网关。'
161
167
  );
168
+ if (instance.callbacks.hasCurrentProject?.() === false) instance.root.querySelector('[data-provider-dialog-subtitle]').textContent = tx(
169
+ 'Manage shared model API profiles. Open a project to choose which provider its future turns use.',
170
+ '管理共享模型 API 配置。进入项目后,再选择该项目后续任务使用的服务。');
162
171
  renderProviderList(instance);
163
172
  renderDetail(instance);
164
173
  renderFooter(instance);
@@ -408,8 +417,8 @@
408
417
  <label class="codex-provider-disclosure">
409
418
  <input type="checkbox" data-provider-disclosure ${disclosureSatisfied ? 'checked' : ''}>
410
419
  <span data-provider-disclosure-text>${escapeHtml(tx(
411
- `Future Codex runs in every Overleaf project tab may send selected project content to ${draft.baseUrl || 'this endpoint'}.`,
412
- `所有 Overleaf 项目标签页的后续 Codex 任务都可能把所选项目内容发送到 ${draft.baseUrl || '此端点'}。`
420
+ `Projects using this shared provider may send task context to ${draft.baseUrl || 'this endpoint'}. Each project chooses its own provider.`,
421
+ `使用此共享服务配置的项目可能把任务上下文发送到 ${draft.baseUrl || '此端点'}。各项目独立选择使用的服务。`
413
422
  ))}</span>
414
423
  </label>
415
424
  <div class="codex-provider-test-row">
@@ -456,7 +465,7 @@
456
465
  'Uses the authentication, model catalog, and provider configuration managed by the local Codex CLI.',
457
466
  '使用本地 Codex CLI 管理的身份验证、模型目录和服务配置。'
458
467
  ))}</p>
459
- ${active
468
+ ${instance.callbacks.hasCurrentProject?.() === false ? '' : active
460
469
  ? `<span class="codex-provider-active-badge">${escapeHtml(tx('Current project', '当前项目'))}</span>`
461
470
  : `<button type="button" class="codex-provider-primary-button" data-provider-action="activate-builtin">${escapeHtml(tx('Use for this project', '用于当前项目'))}</button>`}
462
471
  </div>
@@ -464,6 +473,7 @@
464
473
  }
465
474
 
466
475
  function getCurrentProjectProviderId(instance) {
476
+ if (instance.callbacks.hasCurrentProject?.() === false) return '';
467
477
  const providerId = instance.callbacks.getCurrentProjectProviderId?.();
468
478
  return typeof providerId === 'string' && providerId.trim()
469
479
  ? providerId.trim()
@@ -487,6 +497,7 @@
487
497
  dirty: instance.dirty,
488
498
  destinationChanged,
489
499
  canSave: canSaveCurrentDraft(instance),
500
+ hasProject: instance.callbacks.hasCurrentProject?.() !== false,
490
501
  canActivate: canActivateCurrentProvider(instance)
491
502
  });
492
503
  const saveDisabled = actionState.saveEnabled ? '' : ' disabled aria-disabled="true"';
@@ -508,8 +519,8 @@
508
519
  const dirty = options.dirty === true;
509
520
  return {
510
521
  showSave: isNew || dirty,
511
- showSaveAndUse: (isNew || dirty) && (!active || options.destinationChanged === true),
512
- showUse: !isNew && !active && !dirty,
522
+ showSaveAndUse: options.hasProject !== false && (isNew || dirty) && (!active || options.destinationChanged === true),
523
+ showUse: options.hasProject !== false && !isNew && !active && !dirty,
513
524
  saveEnabled: options.canSave === true,
514
525
  useEnabled: options.canActivate === true
515
526
  };
@@ -662,7 +673,7 @@
662
673
  return;
663
674
  }
664
675
  const focusable = Array.from(instance.root.querySelectorAll('button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled])'))
665
- .filter(element => !element.closest('[hidden]'));
676
+ .filter(element => !element.closest('[hidden]') && (!element.getClientRects || element.getClientRects().length));
666
677
  if (!focusable.length) {
667
678
  return;
668
679
  }
@@ -941,8 +952,8 @@
941
952
  if (!text) return;
942
953
  const baseUrl = getCurrentBaseUrl(instance) || instance.tx('this endpoint', '此端点');
943
954
  text.textContent = instance.tx(
944
- `Future Codex runs in every Overleaf project tab may send selected project content to ${baseUrl}.`,
945
- `所有 Overleaf 项目标签页的后续 Codex 任务都可能把所选项目内容发送到 ${baseUrl}。`
955
+ `Projects using this shared provider may send task context to ${baseUrl}. Each project chooses its own provider.`,
956
+ `使用此共享服务配置的项目可能把任务上下文发送到 ${baseUrl}。各项目独立选择使用的服务。`
946
957
  );
947
958
  }
948
959
 
@@ -984,10 +995,7 @@
984
995
  if (!panel) {
985
996
  return;
986
997
  }
987
- const style = instance.document.defaultView.getComputedStyle(panel);
988
998
  instance.root.dataset.theme = panel.dataset.theme === 'light' ? 'light' : 'dark';
989
- instance.root.style.setProperty('--provider-panel-bg', style.backgroundColor || '#151716');
990
- instance.root.style.setProperty('--provider-panel-color', style.color || '#eceeea');
991
999
  }
992
1000
 
993
1001
  function escapeHtml(value) {
@@ -1011,6 +1019,7 @@
1011
1019
  }
1012
1020
 
1013
1021
  function destroy(instance) {
1022
+ instance.root?.close?.();
1014
1023
  instance.root?.remove?.();
1015
1024
  instance.root = null;
1016
1025
  }
@@ -231,11 +231,46 @@
231
231
  return String(content || '').trim().length > 0;
232
232
  }
233
233
 
234
+ function isRetryReplacementEligible(run = {}, projection = {}) {
235
+ if (!run.id || !['failed', 'interrupted'].includes(run.status)) return false;
236
+ if (run.trackedChangeStatus || run.undoStatus) return false;
237
+ if (['undoOperations', 'undoTrackedChanges', 'undoExpectedFiles', 'undoBaseFiles', 'appliedOperations']
238
+ .some(field => Array.isArray(run[field]) && run[field].length)) return false;
239
+ if (projection.canAccept || projection.canUndo || projection.changedDocument === true) return false;
240
+ if (projection.failureTerminalState === 'needs_review' || projection.terminalState === 'needs_review') return false;
241
+ return projection.changedDocument === false || run.mode === 'ask' || hasZeroWriteReport(run.events);
242
+ }
243
+
244
+ function hasZeroWriteReport(events = []) {
245
+ return (Array.isArray(events) ? events : []).some(event => {
246
+ const rows = Array.isArray(event?.detailStructured?.meta) ? event.detailStructured.meta : [];
247
+ return rows.some(row => row?.key === 'writeResult'
248
+ && /(?:wrote|写入)\s*0\b[\s\S]*(?:skipped|跳过)\s*0\b/i.test(String(row.value || '')));
249
+ });
250
+ }
251
+
252
+ function resolveRetryReplacement({ candidate, activeSessionId, runs, projectRunSettlement } = {}) {
253
+ if (!candidate?.runId || candidate.sessionId !== activeSessionId) return '';
254
+ const source = (Array.isArray(runs) ? runs : []).find(run => run?.id === candidate.runId);
255
+ if (!source) return '';
256
+ const projection = typeof projectRunSettlement === 'function' ? projectRunSettlement(source) : {};
257
+ return isRetryReplacementEligible(source, projection) ? source.id : '';
258
+ }
259
+
260
+ function replaceRunForRetry(runs, sourceRunId, nextRun, maxRuns = 20) {
261
+ const retained = (Array.isArray(runs) ? runs : [])
262
+ .filter(run => !sourceRunId || run?.id !== sourceRunId);
263
+ return [...retained, nextRun].slice(-Math.max(1, maxRuns));
264
+ }
265
+
234
266
  return {
235
267
  buildCodexRunParams,
236
268
  canUseFocusedPartialSnapshot,
237
269
  shouldRestrictWritebackToFocus,
238
270
  buildSessionHistoryResult,
271
+ isRetryReplacementEligible,
272
+ replaceRunForRetry,
273
+ resolveRetryReplacement,
239
274
  truncateSessionHistoryText,
240
275
  normalizeComposerAttachments,
241
276
  normalizeSkillInvocation
@@ -251,10 +251,12 @@
251
251
  for (const selector of ['[data-governance-readonly-patterns]', '[data-governance-writable-patterns]', '[data-sensitive-check-enabled]', '[data-sensitive-confirm-allowed]', '[data-preload-project-context]', '[data-load-codex-local-skills]', '[data-load-codex-overleaf-skills]', '[data-theme-select]', '[data-language-select]']) {
252
252
  const element = container.querySelector(selector);
253
253
  element?.addEventListener?.('change', event => {
254
- instance.callbacks.onInputChange?.(event);
255
- flashSaved(instance, event);
254
+ Promise.resolve(instance.callbacks.onInputChange?.(event))
255
+ .then(() => flashSaved(instance, event)).catch(() => {});
256
+ });
257
+ element?.addEventListener?.('input', event => {
258
+ Promise.resolve(instance.callbacks.onInputChange?.(event)).catch(() => {});
256
259
  });
257
- element?.addEventListener?.('input', event => instance.callbacks.onInputChange?.(event));
258
260
  }
259
261
  // Guardrails: live, non-blocking analysis of the protection globs.
260
262
  for (const selector of ['[data-governance-readonly-patterns]', '[data-governance-writable-patterns]']) {