codex-overleaf-link 2.1.5 → 2.2.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 (66) hide show
  1. package/README.md +27 -23
  2. package/extension/bootstrap/background.js +14 -1
  3. package/extension/bootstrap/manifest.template.json +1 -1
  4. package/extension/runtime-manifest.json +10 -0
  5. package/extension/src/background.js +507 -8
  6. package/extension/src/backgroundUpdateCoordinator.js +284 -19
  7. package/extension/src/content/activeTurnControl.js +179 -0
  8. package/extension/src/content/composerPanel.js +14 -5
  9. package/extension/src/content/contentRuntime.js +330 -309
  10. package/extension/src/content/diagnosticsPanel.js +132 -1
  11. package/extension/src/content/markdownDomRenderer.js +451 -0
  12. package/extension/src/content/markdownText.js +86 -7
  13. package/extension/src/content/mathText.js +49 -4
  14. package/extension/src/content/modelPicker.js +14 -5
  15. package/extension/src/content/nativeChannel.js +7 -1
  16. package/extension/src/content/panelRenderer.js +112 -12
  17. package/extension/src/content/pendingInputView.js +106 -0
  18. package/extension/src/content/projectProviderSelection.js +68 -0
  19. package/extension/src/content/projectSessionCleanup.js +4 -0
  20. package/extension/src/content/providerSettingsCoordinator.js +170 -22
  21. package/extension/src/content/providerSettingsDialog.js +2 -2
  22. package/extension/src/content/recentProjects.js +7 -2
  23. package/extension/src/content/runGuidanceController.js +70 -0
  24. package/extension/src/content/runGuidanceView.js +48 -0
  25. package/extension/src/content/runQueueScheduler.js +260 -0
  26. package/extension/src/content/runTimelineView.js +33 -80
  27. package/extension/src/content/sessionManager.js +2 -2
  28. package/extension/src/content/sessionPersistence.js +62 -0
  29. package/extension/src/content/settingsPanel.js +1 -1
  30. package/extension/src/content/updateNotice.js +80 -1
  31. package/extension/src/page/writebackRouter.js +3 -9
  32. package/extension/src/shared/compatibility.js +2 -1
  33. package/extension/src/shared/i18n.js +38 -0
  34. package/extension/src/shared/providerProfiles.js +3 -2
  35. package/extension/src/shared/runInputQueue.js +253 -0
  36. package/extension/src/shared/sessionState.js +45 -22
  37. package/extension/src/shared/storageDb.js +39 -44
  38. package/extension/src/shared/storageMigration.js +123 -3
  39. package/extension/src/shared/storageRunActions.js +34 -1
  40. package/extension/src/shared/updateConsent.js +15 -4
  41. package/extension/styles/panel.css +319 -20
  42. package/extension/vendor/katex/katex.min.css +1 -1
  43. package/extension/vendor/markdown-it/LICENSE +22 -0
  44. package/extension/vendor/markdown-it/SOURCE.json +7 -0
  45. package/extension/vendor/markdown-it/VERSION +1 -0
  46. package/extension/vendor/markdown-it/markdown-it.min.js +3 -0
  47. package/native-host/src/anthropicBridgeRequest.js +3 -3
  48. package/native-host/src/anthropicBridgeResponse.js +58 -17
  49. package/native-host/src/anthropicMessagesBridge.js +156 -23
  50. package/native-host/src/chatBridgeRequest.js +1 -1
  51. package/native-host/src/chatBridgeResponse.js +12 -8
  52. package/native-host/src/chatCompletionsBridge.js +22 -40
  53. package/native-host/src/codexPromptAssembly.js +3 -1
  54. package/native-host/src/codexProviderLaunch.js +6 -3
  55. package/native-host/src/codexSessionRunner.js +65 -65
  56. package/native-host/src/codexSessionTiming.js +45 -0
  57. package/native-host/src/index.js +58 -3
  58. package/native-host/src/managedInstall.js +45 -7
  59. package/native-host/src/providerProfile.js +2 -2
  60. package/native-host/src/providerStreamLifecycle.js +100 -0
  61. package/native-host/src/responsesPassthroughBridge.js +636 -0
  62. package/native-host/src/taskRunnerRuntime.js +81 -2
  63. package/native-host/src/updateArchive.js +4 -3
  64. package/native-host/src/updateManager.js +306 -19
  65. package/native-host/src/updateTrust.js +1 -1
  66. package/package.json +3 -1
@@ -0,0 +1,260 @@
1
+ (function initCodexOverleafRunQueueScheduler(root, factory) {
2
+ if (typeof module === 'object' && module.exports) {
3
+ module.exports = factory(require('../shared/runInputQueue'));
4
+ } else {
5
+ root.CodexOverleafRunQueueScheduler = factory(root.CodexOverleafRunInputQueue);
6
+ }
7
+ })(typeof window !== 'undefined' ? window : globalThis, function runQueueSchedulerFactory(Queue) {
8
+ 'use strict';
9
+
10
+ function create(options = {}) {
11
+ let scheduling = false;
12
+
13
+ function dequeue(queue, itemId, context = {}) {
14
+ const item = itemId ? queue.find(entry => entry.id === itemId) : null;
15
+ if (item) {
16
+ options.onDequeued?.(item, context);
17
+ }
18
+ return itemId ? Queue.remove(queue, itemId) : queue;
19
+ }
20
+
21
+ async function afterSettlement({ sessionId, status, queueItemId = '', continueQueue = false } = {}) {
22
+ let queue = options.getQueue?.(sessionId) || [];
23
+ if (status === 'completed') {
24
+ queue = dequeue(queue, queueItemId, { sessionId, reason: 'completed' });
25
+ } else if (continueQueue) {
26
+ queue = dequeue(queue, queueItemId, { sessionId, reason: 'continue_queue' });
27
+ } else if (queue.length) {
28
+ queue = Queue.pauseAll(queue, status || 'run_did_not_complete');
29
+ }
30
+ options.setQueue?.(sessionId, queue);
31
+ await options.persist?.();
32
+ options.onChange?.(sessionId);
33
+ if (status === 'completed' || continueQueue) {
34
+ scheduleOne(sessionId);
35
+ }
36
+ }
37
+
38
+ function scheduleOne(sessionId) {
39
+ if (scheduling || options.isRunning?.() || options.canStart?.(sessionId) === false) {
40
+ return false;
41
+ }
42
+ const claimed = Queue.claimNext(options.getQueue?.(sessionId) || [], {
43
+ randomUUID: options.randomUUID
44
+ });
45
+ if (!claimed.ok) {
46
+ return false;
47
+ }
48
+ scheduling = true;
49
+ options.setQueue?.(sessionId, claimed.queue);
50
+ Promise.resolve(options.persist?.())
51
+ .then(() => {
52
+ options.onChange?.(sessionId);
53
+ scheduling = false;
54
+ return options.start?.(claimed.item, sessionId);
55
+ })
56
+ .catch(error => {
57
+ scheduling = false;
58
+ const paused = Queue.pauseAll(options.getQueue?.(sessionId) || claimed.queue, error?.code || 'start_failed');
59
+ options.setQueue?.(sessionId, paused);
60
+ options.onChange?.(sessionId);
61
+ return options.persist?.();
62
+ });
63
+ return true;
64
+ }
65
+
66
+ async function markExecuting(sessionId, itemId, _runId) {
67
+ const queue = options.getQueue?.(sessionId) || [];
68
+ options.setQueue?.(
69
+ sessionId,
70
+ dequeue(queue, itemId, { sessionId, runId: _runId, reason: 'executing' })
71
+ );
72
+ await options.persist?.();
73
+ options.onChange?.(sessionId);
74
+ }
75
+
76
+ async function resume(sessionId) {
77
+ options.setQueue?.(sessionId, Queue.resumeAll(options.getQueue?.(sessionId) || []));
78
+ await options.persist?.();
79
+ options.onChange?.(sessionId);
80
+ scheduleOne(sessionId);
81
+ }
82
+
83
+ return { afterSettlement, markExecuting, resume, scheduleOne };
84
+ }
85
+
86
+ function createCoordinator(options = {}) {
87
+ let view = null;
88
+ const scheduler = create({
89
+ getQueue: sessionId => options.getSession?.(sessionId)?.pendingInputs || [],
90
+ setQueue: (sessionId, pendingInputs) => options.setQueue?.(sessionId, pendingInputs),
91
+ persist: () => options.save?.(),
92
+ isRunning: () => Boolean(options.getCurrentRun?.()),
93
+ canStart: sessionId => options.canStart?.(sessionId) !== false,
94
+ start: (item, sessionId) => startQueuedInput(item, sessionId),
95
+ onChange: () => render(),
96
+ onDequeued: (item, context) => options.removeGuidance?.(item.id, {
97
+ sessionId: context.sessionId,
98
+ recordId: item.sourceRunId || ''
99
+ }),
100
+ randomUUID: options.randomUUID
101
+ });
102
+
103
+ function queueComposerInput() {
104
+ options.readInputs?.();
105
+ const text = String(options.getTask?.() || '').trim();
106
+ if (!text) {
107
+ return;
108
+ }
109
+ if (options.hasAttachments?.()) {
110
+ options.toast?.(options.tr?.('queuedInputAttachmentsUnsupported'), 'warning');
111
+ return;
112
+ }
113
+ const session = options.getActiveSession?.();
114
+ const result = Queue.enqueue(session?.pendingInputs || [], {
115
+ text,
116
+ sourceRunId: options.getCurrentRun?.()?.recordId || '',
117
+ payload: options.capturePayload?.() || {}
118
+ }, {
119
+ randomUUID: options.randomUUID
120
+ });
121
+ if (!result.ok) {
122
+ options.toast?.(options.tr?.('queuedInputError_' + (result.error?.code || 'queue_full')), 'warning');
123
+ return;
124
+ }
125
+ options.setQueue?.(session.id, result.queue);
126
+ // A queued follow-up has not reached the active model turn yet. Keep it
127
+ // exclusively in the queue UI until the user explicitly guides the
128
+ // active turn, or until the scheduler promotes it to the next run.
129
+ options.clearTask?.();
130
+ render();
131
+ options.saveSoon?.();
132
+ }
133
+
134
+ async function guide(itemId) {
135
+ const session = options.getActiveSession?.();
136
+ const item = (session?.pendingInputs || []).find(entry => entry.id === itemId);
137
+ const run = options.getCurrentRun?.();
138
+ const binding = run?.activeTurn;
139
+ if (!session || !item || !run || run.sessionId !== session.id || !binding?.threadId || !binding?.turnId) {
140
+ options.toast?.(options.tr?.('queuedInputGuideUnavailable'), 'warning');
141
+ return;
142
+ }
143
+ options.setQueue?.(session.id, Queue.markSteering(session.pendingInputs, itemId));
144
+ render();
145
+ await options.save?.();
146
+ try {
147
+ const response = await options.sendSteer?.({
148
+ requestId: run.nativeRequestId,
149
+ projectKey: run.runProjectId,
150
+ threadId: binding.threadId,
151
+ expectedTurnId: binding.turnId,
152
+ clientUserMessageId: item.clientUserMessageId,
153
+ input: [{ type: 'text', text: item.text }]
154
+ });
155
+ const latest = options.getSession?.(session.id);
156
+ options.setQueue?.(
157
+ session.id,
158
+ response?.ok
159
+ ? Queue.remove(latest?.pendingInputs || [], itemId)
160
+ : Queue.returnToQueue(latest?.pendingInputs || [], itemId, response?.error?.code || 'steer_failed')
161
+ );
162
+ if (response?.ok) {
163
+ if (typeof options.appendGuidance === 'function') {
164
+ options.appendGuidance(item.text, {
165
+ guidanceId: item.id,
166
+ sessionId: session.id,
167
+ recordId: run.recordId,
168
+ status: 'completed'
169
+ });
170
+ } else {
171
+ options.appendEvent?.(options.tr?.('queuedInputGuided'), 'completed');
172
+ }
173
+ } else {
174
+ options.toast?.(response?.error?.message || options.tr?.('queuedInputGuideFailed'), 'warning');
175
+ }
176
+ render();
177
+ await options.save?.();
178
+ } catch (error) {
179
+ const latest = options.getSession?.(session.id);
180
+ const paused = (latest?.pendingInputs || []).map(entry => entry.id === itemId
181
+ ? { ...entry, status: 'paused', pauseReason: 'steer_delivery_unknown' }
182
+ : entry);
183
+ options.setQueue?.(session.id, Queue.normalizeQueue(paused));
184
+ options.toast?.(error?.message || options.tr?.('queuedInputGuideFailed'), 'warning');
185
+ render();
186
+ await options.save?.();
187
+ }
188
+ }
189
+
190
+ function remove(itemId) {
191
+ const session = options.getActiveSession?.();
192
+ if (!session) {
193
+ return;
194
+ }
195
+ const item = (session.pendingInputs || []).find(entry => entry.id === itemId);
196
+ if (item) {
197
+ options.removeGuidance?.(item.id, {
198
+ sessionId: session.id,
199
+ recordId: item.sourceRunId || ''
200
+ });
201
+ }
202
+ options.setQueue?.(session.id, Queue.remove(session.pendingInputs || [], itemId));
203
+ render();
204
+ options.saveSoon?.();
205
+ }
206
+
207
+ function resume() {
208
+ const session = options.getActiveSession?.();
209
+ if (session) {
210
+ scheduler.resume(session.id).catch(error => options.toast?.(error.message, 'warning'));
211
+ }
212
+ }
213
+
214
+ function render() {
215
+ const container = options.getContainer?.();
216
+ if (!container) {
217
+ return;
218
+ }
219
+ if (!view || view.container !== container) {
220
+ view = options.createView?.({
221
+ container,
222
+ tr: options.tr,
223
+ onGuide: itemId => guide(itemId).catch(error => options.toast?.(error.message, 'warning')),
224
+ onRemove: remove,
225
+ onResume: resume
226
+ });
227
+ if (view) {
228
+ view.container = container;
229
+ }
230
+ }
231
+ const session = options.getActiveSession?.();
232
+ const run = options.getCurrentRun?.();
233
+ view?.render?.(session?.pendingInputs || [], {
234
+ running: Boolean(run),
235
+ canGuide: Boolean(run?.activeTurn?.turnId)
236
+ });
237
+ }
238
+
239
+ async function startQueuedInput(item, sessionId) {
240
+ if (options.canStart?.(sessionId) === false || options.getCurrentRun?.()) {
241
+ const error = new Error('Queued input cannot start in the current session.');
242
+ error.code = 'queue_session_not_active';
243
+ throw error;
244
+ }
245
+ options.applyQueuedInput?.(item);
246
+ await options.run?.(item);
247
+ }
248
+
249
+ return {
250
+ guide,
251
+ queueComposerInput,
252
+ remove,
253
+ render,
254
+ resume,
255
+ scheduler
256
+ };
257
+ }
258
+
259
+ return { create, createCoordinator };
260
+ });
@@ -1,6 +1,7 @@
1
1
  (function initCodexOverleafRunTimelineView() {
2
2
  'use strict';
3
3
 
4
+ const RunGuidanceView = window.CodexOverleafRunGuidanceView;
4
5
  // Run-timeline view — the timeline render pipeline carved out of
5
6
  // contentRuntime.js (v1.4.6 structural-debt phase 2): the scroll engine +
6
7
  // jump-to-latest button, the live-elapsed tick and collapsed-header summary,
@@ -43,7 +44,6 @@
43
44
  } = deps;
44
45
 
45
46
  let logAutoFollow = true;
46
- let runSearchQuery = '';
47
47
  let userScrollIntentUntil = 0;
48
48
  // Scroll engine: a single rAF coalesces a burst of scroll requests into one
49
49
  // write per frame (streaming can fire ~25/sec); `scrollLogPendingForce`
@@ -308,86 +308,12 @@
308
308
  truncated.textContent = tr('runsTruncatedNote');
309
309
  log.append(truncated);
310
310
  }
311
- // In-session search (v1.8.0): filter run cards by task or report text.
312
- // Rendered with the same >= 3 threshold as the turn navigator; filtering
313
- // is pure display (hidden attribute), state is kept across re-renders.
314
- if (runs.length < 3 && runSearchQuery) {
315
- // No search box is rendered below this threshold — a stale query from a
316
- // bigger session would silently blank this log with no way to clear it.
317
- runSearchQuery = '';
318
- }
319
- if (runs.length >= 3) {
320
- const search = document.createElement('input');
321
- search.type = 'search';
322
- search.className = 'run-search';
323
- search.setAttribute('data-run-search', '');
324
- search.placeholder = tr('runSearchPlaceholder');
325
- search.setAttribute('aria-label', tr('runSearchPlaceholder'));
326
- search.value = runSearchQuery;
327
- search.addEventListener('input', () => {
328
- runSearchQuery = search.value;
329
- applyRunSearchFilter(log);
330
- });
331
- log.append(search);
332
- }
333
- // Turn navigation (v1.7.5): once the log holds enough turns that
334
- // scroll-hunting hurts, offer a jump-to-turn dropdown pinned above them.
335
- if (runs.length >= 3) {
336
- const nav = document.createElement('select');
337
- nav.className = 'run-turn-nav';
338
- nav.setAttribute('data-run-turn-nav', '');
339
- nav.setAttribute('aria-label', tr('runTurnNavLabel'));
340
- const placeholder = document.createElement('option');
341
- placeholder.value = '';
342
- placeholder.textContent = tr('runTurnNavLabel');
343
- nav.append(placeholder);
344
- runs.forEach((run, index) => {
345
- const option = document.createElement('option');
346
- option.value = run.id;
347
- option.textContent = `#${index + 1} ${String(run.task || '').replace(/\s+/g, ' ').slice(0, 48)}`;
348
- nav.append(option);
349
- });
350
- nav.addEventListener('change', () => {
351
- if (!nav.value) {
352
- return;
353
- }
354
- const card = log.querySelector(`[data-run-id="${cssEscape(nav.value)}"]`);
355
- card?.scrollIntoView({ block: 'start' });
356
- nav.value = '';
357
- });
358
- log.append(nav);
359
- }
360
311
  for (const run of runs) {
361
312
  log.append(renderRunCard(run));
362
313
  }
363
- if (runSearchQuery) {
364
- applyRunSearchFilter(log);
365
- }
366
314
  scrollLogToBottom({ force: true });
367
315
  }
368
316
 
369
- // Case-insensitive substring match over the run's task text and its
370
- // report/event narration. Empty query shows everything again.
371
- function applyRunSearchFilter(log) {
372
- const query = String(runSearchQuery || '').trim().toLowerCase();
373
- const state = getState();
374
- const matchingIds = new Set();
375
- if (query) {
376
- for (const run of state.runs || []) {
377
- const haystack = [
378
- run.task || '',
379
- ...(run.events || []).map(event => `${event.title || ''} ${typeof event.detail === 'string' ? event.detail : ''}`)
380
- ].join(' ').toLowerCase();
381
- if (haystack.includes(query)) {
382
- matchingIds.add(run.id);
383
- }
384
- }
385
- }
386
- for (const card of log.querySelectorAll('[data-run-id]')) {
387
- card.hidden = Boolean(query) && !matchingIds.has(card.dataset.runId);
388
- }
389
- }
390
-
391
317
  function renderRunCard(run) {
392
318
  const root = document.createElement('section');
393
319
  root.className = 'transcript-turn run-card';
@@ -404,6 +330,7 @@
404
330
  <div class="transcript-turn-main">
405
331
  <div class="run-attachments codex-attachment-preview-list" data-run-attachments hidden></div>
406
332
  <div class="run-prompt" data-run-task></div>
333
+ <div class="run-guidance-list" data-run-guidance></div>
407
334
  <div class="run-turn-meta">
408
335
  <button type="button" data-run-accept hidden title="Accept this run's tracked changes in Overleaf">Accept changes</button>
409
336
  <button type="button" data-run-undo hidden title="Undo this run's writes to Overleaf">Undo</button>
@@ -413,19 +340,25 @@
413
340
  <span class="run-status" data-run-status></span>
414
341
  <span class="run-scan" aria-hidden="true"></span>
415
342
  </summary>
416
- <div class="run-activity-list" data-run-events></div>
417
343
  </details>
344
+ <div class="run-activity-list" data-run-events></div>
418
345
  <div class="run-report" data-run-report aria-live="polite" hidden></div>
419
346
  </div>
420
347
  `;
421
348
 
422
349
  renderAttachmentPreviewList(run.attachments, root.querySelector('[data-run-attachments]'), { readonly: true });
423
- root.querySelector('[data-run-task]').textContent = run.task || '';
350
+ const runTask = root.querySelector('[data-run-task]');
351
+ runTask.classList.add('run-user-message');
352
+ runTask.textContent = run.task || '';
424
353
  root.querySelector('[data-run-status]').textContent = getRunStatusText(run);
425
354
  const process = root.querySelector('[data-run-process]');
426
355
  process.open = run.status === 'running';
427
356
 
357
+ const guidance = root.querySelector('[data-run-guidance]');
428
358
  const events = root.querySelector('[data-run-events]');
359
+ const eventListId = `codex-run-events-${String(run.id || 'unknown').replace(/[^a-zA-Z0-9_-]/g, '-')}`;
360
+ events.id = eventListId;
361
+ root.querySelector('[data-run-process-summary]')?.setAttribute('aria-controls', eventListId);
429
362
  const report = root.querySelector('[data-run-report]');
430
363
  if ((run.events || []).length >= 300) {
431
364
  const truncated = document.createElement('div');
@@ -433,12 +366,28 @@
433
366
  truncated.textContent = tr('eventsTruncatedNote');
434
367
  events.append(truncated);
435
368
  }
369
+ const guidanceTarget = run.status === 'running' ? events : guidance;
370
+ const renderedGuidanceIds = new Set();
371
+ const completedLegacyGuidance = new Set((run.events || [])
372
+ .filter(event => event.kind === 'guidance' && !event.guidanceId && event.status === 'completed')
373
+ .map(event => event.title));
436
374
  for (const event of run.events || []) {
437
375
  if (event.kind === 'report') {
438
376
  report.hidden = false;
439
377
  report.replaceChildren(renderCompletionReport(event, run));
440
378
  } else if (event.kind === 'technical') {
441
379
  continue;
380
+ } else if (RunGuidanceView.getGuidanceText(event)) {
381
+ if (event.guidanceId && renderedGuidanceIds.has(event.guidanceId)) {
382
+ continue;
383
+ }
384
+ if (!event.guidanceId && event.status === 'queued' && completedLegacyGuidance.has(event.title)) {
385
+ continue;
386
+ }
387
+ if (event.guidanceId) {
388
+ renderedGuidanceIds.add(event.guidanceId);
389
+ }
390
+ guidanceTarget.append(renderRunEvent(event));
442
391
  } else if (event.kind === 'stream') {
443
392
  upsertStreamEvent({ events }, event);
444
393
  } else {
@@ -465,7 +414,11 @@
465
414
  }
466
415
 
467
416
  function renderRunEvent(event) {
468
- return renderActivityLine(sanitizeRunEventForRender(event));
417
+ const normalized = sanitizeRunEventForRender(event);
418
+ const guidanceText = RunGuidanceView.getGuidanceText(normalized);
419
+ return guidanceText
420
+ ? RunGuidanceView.renderGuidanceMessage(normalized, guidanceText, tx)
421
+ : renderActivityLine(normalized);
469
422
  }
470
423
 
471
424
  function upsertStreamEvent(view, event) {
@@ -481,7 +434,7 @@
481
434
  const text = existing.querySelector('[data-stream-text]');
482
435
  if (text) {
483
436
  if (event.streamRole === 'assistant') {
484
- renderMarkdownBlockText(text, event.title || '');
437
+ renderMarkdownBlockText(text, event.title || '', { streaming: event.status === 'running' });
485
438
  } else {
486
439
  renderMarkdownInlineText(text, event.title || '');
487
440
  }
@@ -503,7 +456,7 @@
503
456
  text.className = 'run-stream-text';
504
457
  text.dataset.streamText = '';
505
458
  if (event.streamRole === 'assistant') {
506
- renderMarkdownBlockText(text, event.title || '');
459
+ renderMarkdownBlockText(text, event.title || '', { streaming: event.status === 'running' });
507
460
  } else {
508
461
  renderMarkdownInlineText(text, event.title || '');
509
462
  }
@@ -159,7 +159,7 @@
159
159
  return;
160
160
  }
161
161
  setState(deleteSession(getState(), sessionId));
162
- await saveState();
162
+ await saveState({ deletedSessionIds: [sessionId] });
163
163
  applyStateToPanel();
164
164
  showPluginToast(tr('deleteSessionDeletedToast'), { status: 'info' });
165
165
  return;
@@ -186,7 +186,7 @@
186
186
  }
187
187
 
188
188
  setState(deleteSession(getState(), sessionId));
189
- await saveState();
189
+ await saveState({ deletedSessionIds: [sessionId] });
190
190
  applyStateToPanel();
191
191
 
192
192
  try {
@@ -0,0 +1,62 @@
1
+ (function initCodexOverleafSessionPersistence() {
2
+ 'use strict';
3
+
4
+ function migration() {
5
+ return window.CodexOverleafStorageMigration;
6
+ }
7
+
8
+ async function loadTombstones() {
9
+ return migration()?.loadSessionTombstones?.() || {};
10
+ }
11
+
12
+ async function getDeletedSessionIds(projectId) {
13
+ const Migration = migration();
14
+ return Migration?.getDeletedSessionIds?.(await loadTombstones(), projectId) || [];
15
+ }
16
+
17
+ async function addTombstones(projectId, sessionIds) {
18
+ return migration()?.addSessionTombstones?.(projectId, sessionIds) || loadTombstones();
19
+ }
20
+
21
+ function isVisibleRecord(record, deletedIds, accountScopeId) {
22
+ return Boolean(record)
23
+ && !(deletedIds || []).includes(record.id)
24
+ && (!accountScopeId || record.accountScopeId === accountScopeId);
25
+ }
26
+
27
+ async function writeSessions(options = {}) {
28
+ const { StorageDb, projectId } = options;
29
+ const Migration = options.Migration || migration();
30
+ const requested = (Array.isArray(options.deletedSessionIds) ? options.deletedSessionIds : [])
31
+ .filter(id => typeof id === 'string' && id);
32
+ const tombstones = requested.length && Migration?.addSessionTombstones
33
+ ? await Migration.addSessionTombstones(projectId, requested)
34
+ : await Migration?.loadSessionTombstones?.() || {};
35
+ const deleted = new Set(requested);
36
+ for (const id of Migration?.getDeletedSessionIds?.(tombstones, projectId) || []) deleted.add(id);
37
+ const sessionRecords = (options.sessionRecords || []).filter(record => !deleted.has(record.id));
38
+ const existingSessions = await StorageDb.getAllByIndex('sessions', 'projectId', projectId);
39
+ const deletedExisting = existingSessions.filter(record => deleted.has(record.id));
40
+ if (deletedExisting.length) {
41
+ await Promise.all(deletedExisting.map(record => StorageDb.deleteRecord('sessions', record.id)));
42
+ }
43
+ const existingById = new Map(existingSessions
44
+ .filter(record => !deleted.has(record.id))
45
+ .map(record => [record.id, record]));
46
+ const writable = sessionRecords.filter(record => {
47
+ const existing = existingById.get(record.id);
48
+ if (!existing) return true;
49
+ return (Date.parse(record.updatedAt || '') || 0) >= (Date.parse(existing.updatedAt || '') || 0);
50
+ });
51
+ if (writable.length) await StorageDb.putRecords('sessions', writable);
52
+ return writable;
53
+ }
54
+
55
+ window.CodexOverleafSessionPersistence = {
56
+ loadTombstones,
57
+ getDeletedSessionIds,
58
+ addTombstones,
59
+ isVisibleRecord,
60
+ writeSessions
61
+ };
62
+ })();
@@ -154,7 +154,7 @@
154
154
  <div class="codex-set-card codex-provider-settings-entry">
155
155
  <div class="codex-provider-settings-entry-copy">
156
156
  <p class="codex-set-row-help" data-i18n="providerSettingsHelp">Configure experimental third-party model APIs. Compatibility varies by provider and gateway.</p>
157
- <p class="codex-provider-settings-summary" data-provider-settings-summary data-tone="ok" data-i18n="providerSettingsBuiltin">Built-in Codex is active</p>
157
+ <p class="codex-provider-settings-summary" data-provider-settings-summary data-tone="loading">${t(instance, 'providerSettingsLoading')}</p>
158
158
  </div>
159
159
  <button type="button" class="codex-set-btn" data-provider-settings-open data-i18n="providerSettingsConfigure">Configure</button>
160
160
  </div>
@@ -9,7 +9,10 @@
9
9
  let getLocale = () => '';
10
10
  let settingsRoot = null;
11
11
  let settingsActionInFlight = false;
12
+ let restartWatchdog = null;
13
+ let manualCommandCopied = false;
12
14
  const ACTIVE_UPDATE_STATES = new Set(['downloading', 'staged', 'waiting_for_idle', 'applying', 'awaiting_health']);
15
+ const RESTART_WATCHDOG_MS = 30 * 1000;
13
16
 
14
17
  function mount(panelLike, options = {}) {
15
18
  if (typeof options.getLocale === 'function') {
@@ -60,6 +63,20 @@
60
63
  async function handleClick(event) {
61
64
  const action = event.target?.closest?.('[data-update-notice-action]')?.dataset?.updateNoticeAction;
62
65
  if (!action || actionInFlight) return;
66
+ if (action === 'copy-manual') {
67
+ try {
68
+ await navigator.clipboard.writeText(manualUpdateCommand(currentView?.state));
69
+ manualCommandCopied = true;
70
+ render();
71
+ setTimeout(() => {
72
+ manualCommandCopied = false;
73
+ render();
74
+ }, 1800);
75
+ } catch (_error) {
76
+ manualCommandCopied = false;
77
+ }
78
+ return;
79
+ }
63
80
  actionInFlight = true;
64
81
  render();
65
82
  try {
@@ -121,6 +138,7 @@
121
138
  }
122
139
 
123
140
  function buildRestartingView() {
141
+ scheduleRestartWatchdog();
124
142
  return {
125
143
  ...(currentView || {}),
126
144
  state: {
@@ -134,6 +152,43 @@
134
152
  };
135
153
  }
136
154
 
155
+ function scheduleRestartWatchdog() {
156
+ clearTimeout(restartWatchdog);
157
+ restartWatchdog = setTimeout(async () => {
158
+ try {
159
+ const view = await request('codex-overleaf/consent-update-get-state');
160
+ if (view?.state && !ACTIVE_UPDATE_STATES.has(view.state.state)) {
161
+ currentView = view;
162
+ } else {
163
+ currentView = buildFailedView('update_recovery_timeout', tx(
164
+ 'The update did not finish restarting. Retry or use the manual recovery command.',
165
+ '更新重启未能完成。请重试或使用手动恢复命令。'
166
+ ));
167
+ }
168
+ } catch (_error) {
169
+ currentView = buildFailedView('update_recovery_timeout', tx(
170
+ 'The update did not finish restarting. Retry or use the manual recovery command.',
171
+ '更新重启未能完成。请重试或使用手动恢复命令。'
172
+ ));
173
+ }
174
+ render();
175
+ }, RESTART_WATCHDOG_MS);
176
+ }
177
+
178
+ function buildFailedView(code, message) {
179
+ return {
180
+ ...(currentView || {}),
181
+ state: {
182
+ ...(currentView?.state || {}),
183
+ state: 'failed',
184
+ code,
185
+ message
186
+ },
187
+ progress: { value: 0, determinate: true, phase: 'failed' },
188
+ showPanel: true
189
+ };
190
+ }
191
+
137
192
  function bindSettingsControls(panel) {
138
193
  settingsRoot = panel;
139
194
  const button = panel.querySelector?.('[data-check-updates]');
@@ -184,6 +239,10 @@
184
239
 
185
240
  const focusedAction = document.activeElement?.dataset?.updateNoticeAction || '';
186
241
  const state = currentView.state || {};
242
+ if (state.state !== 'awaiting_health') {
243
+ clearTimeout(restartWatchdog);
244
+ restartWatchdog = null;
245
+ }
187
246
  const progress = currentView.progress || {};
188
247
  const copy = getCopy(state);
189
248
  notice.hidden = false;
@@ -201,6 +260,16 @@
201
260
  detail.textContent = copy.detail;
202
261
  body.append(eyebrow, title, detail);
203
262
 
263
+ if (state.state === 'failed') {
264
+ const recovery = document.createElement('span');
265
+ recovery.className = 'codex-update-notice-recovery';
266
+ recovery.textContent = tx('Manual recovery:', '手动恢复:');
267
+ const command = document.createElement('code');
268
+ command.className = 'codex-update-notice-command';
269
+ command.textContent = manualUpdateCommand(state);
270
+ body.append(recovery, command);
271
+ }
272
+
204
273
  if (!['update_available', 'failed', 'rolled_back'].includes(state.state)) {
205
274
  const bar = document.createElement('div');
206
275
  bar.className = 'codex-update-notice-progress' + (progress.determinate ? '' : ' is-indeterminate');
@@ -290,7 +359,10 @@
290
359
  return [{ id: 'later', label: tx('Later', '稍后'), primary: false }];
291
360
  }
292
361
  if (['failed', 'rolled_back'].includes(state)) {
293
- return [{ id: 'retry', label: tx('Retry', '重试'), primary: true }];
362
+ return [
363
+ { id: 'copy-manual', label: manualCommandCopied ? tx('Copied', '已复制') : tx('Copy command', '复制命令'), primary: false },
364
+ { id: 'retry', label: tx('Retry', '重试'), primary: true }
365
+ ];
294
366
  }
295
367
  if (state === 'committed') {
296
368
  return [{ id: 'dismiss', label: tx('Done', '完成'), primary: true }];
@@ -350,6 +422,13 @@
350
422
  }[state.state] || { eyebrow: '', title: '', detail: '' };
351
423
  }
352
424
 
425
+ function manualUpdateCommand(state = {}) {
426
+ const version = /^\d+\.\d+\.\d+$/.test(String(state.latestVersion || ''))
427
+ ? state.latestVersion
428
+ : 'latest';
429
+ return `npm exec --yes codex-overleaf-link@${version} -- install-managed`;
430
+ }
431
+
353
432
  function blockerCopy(value) {
354
433
  return {
355
434
  unsaved: tx('Overleaf has not confirmed that this document is saved.', 'Overleaf 尚未确认当前文档已保存。'),