codex-overleaf-link 2.3.4 → 2.3.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +325 -211
  2. package/extension/bootstrap/manifest.template.json +1 -1
  3. package/extension/src/background.js +9 -0
  4. package/extension/src/backgroundUpdateCoordinator.js +88 -4
  5. package/extension/src/content/composerPanel.js +1 -1
  6. package/extension/src/content/contentRuntime.js +51 -35
  7. package/extension/src/content/diagnosticsController.js +13 -3
  8. package/extension/src/content/diagnosticsPanel.js +1 -1
  9. package/extension/src/content/generated/content.bundle.js +1233 -298
  10. package/extension/src/content/generated/content.bundle.meta.json +8 -3
  11. package/extension/src/content/globalPreferencesController.js +125 -0
  12. package/extension/src/content/legacyGlobalRegistry.js +4 -0
  13. package/extension/src/content/otWarmMirror.js +95 -65
  14. package/extension/src/content/otWarmMirrorController.js +102 -1
  15. package/extension/src/content/panelMaintenance.js +26 -15
  16. package/extension/src/content/panelRenderer.js +6 -1
  17. package/extension/src/content/projectSettingsCoordinator.js +1 -0
  18. package/extension/src/content/providerSettingsCoordinator.js +16 -0
  19. package/extension/src/content/providerSettingsDialog.js +24 -15
  20. package/extension/src/content/runFailureNotice.js +104 -0
  21. package/extension/src/content/runScrollLayout.js +124 -0
  22. package/extension/src/content/runTimelineView.js +36 -10
  23. package/extension/src/content/settingsPanel.js +32 -13
  24. package/extension/src/content/themeController.js +3 -0
  25. package/extension/src/content/updateNotice.js +24 -4
  26. package/extension/src/page/overleafRealtimeObserver.js +116 -24
  27. package/extension/src/shared/compatibility.js +1 -1
  28. package/extension/src/shared/globalPreferences.js +131 -0
  29. package/extension/src/shared/i18n.js +79 -39
  30. package/extension/src/shared/managedUpdateProjection.js +1 -1
  31. package/extension/src/shared/otText.js +0 -1
  32. package/extension/src/shared/sessionState.js +17 -21
  33. package/extension/src/shared/sessionTitle.js +120 -0
  34. package/extension/src/shared/updateRuntimeIdentity.js +32 -0
  35. package/extension/styles/panel.css +90 -16
  36. package/native-host/src/mirrorWorkspace.js +20 -1
  37. package/native-host/src/releaseProxy.js +177 -0
  38. package/native-host/src/releaseTransport.js +154 -0
  39. package/native-host/src/updateManager.js +40 -39
  40. package/package.json +1 -1
  41. package/scripts/install-managed.mjs +1 -1
@@ -5,12 +5,13 @@
5
5
  require('./settlementFacts'),
6
6
  require('./runInputQueue'),
7
7
  require('./lineReferences'),
8
- require('./pathRedaction')
8
+ require('./pathRedaction'),
9
+ require('./sessionTitle')
9
10
  );
10
11
  } else {
11
12
  root.CodexOverleafModuleRegistry.define(
12
13
  'SessionState',
13
- ['I18n', 'SettlementFacts', 'RunInputQueue', 'LineReferences', 'PathRedaction'],
14
+ ['I18n', 'SettlementFacts', 'RunInputQueue', 'LineReferences', 'PathRedaction', 'SessionTitle'],
14
15
  factory
15
16
  );
16
17
  }
@@ -19,7 +20,8 @@
19
20
  SettlementFacts,
20
21
  RunInputQueue,
21
22
  LineReferences,
22
- PathRedaction
23
+ PathRedaction,
24
+ SessionTitle
23
25
  ) {
24
26
  'use strict';
25
27
 
@@ -402,6 +404,12 @@
402
404
  ) {
403
405
  next.title = session.title;
404
406
  next.titleSource = 'manual';
407
+ } else if (session.titleSource === 'auto' && patch.titleSource !== 'manual'
408
+ && typeof patch.title === 'string' && patch.title.trim() && session.runs?.length
409
+ && SessionTitle.isGeneric(sanitizeAutoTitle(patch.title))
410
+ && !SessionTitle.isGeneric(sanitizeAutoTitle(session.title))) {
411
+ next.title = session.title;
412
+ next.titleSource = 'auto';
405
413
  }
406
414
  return normalizeSession({
407
415
  ...next
@@ -472,12 +480,7 @@
472
480
  }
473
481
 
474
482
  function deriveSessionTitle(runs, task) {
475
- const taskTitle = sanitizeAutoTitle(task);
476
- if (taskTitle) {
477
- return taskTitle;
478
- }
479
- const firstRunTask = Array.isArray(runs) && runs.length ? runs[0]?.task : '';
480
- return sanitizeAutoTitle(firstRunTask);
483
+ return SessionTitle.derive(runs, task, { sanitize: sanitizeAssistantVisibleText, maxChars: SESSION_AUTO_TITLE_CHARS });
481
484
  }
482
485
 
483
486
  // Rename a session with the placeholder/derived-title ghost guard (mirrors
@@ -519,21 +522,14 @@
519
522
  if (titleSource === 'manual') {
520
523
  return normalizeTextField(rawTitle === LEGACY_DEFAULT_SESSION_TITLE ? '' : rawTitle, STORAGE_DEFAULT_LIMITS.sessionTitleChars);
521
524
  }
522
- return sanitizeAutoTitle(rawTitle === LEGACY_DEFAULT_SESSION_TITLE ? '' : rawTitle) || derivedTitle || '';
525
+ const current = sanitizeAutoTitle(rawTitle === LEGACY_DEFAULT_SESSION_TITLE ? '' : rawTitle);
526
+ if (!current || SessionTitle.isGeneric(current)) return derivedTitle || current || '';
527
+ if (current.endsWith('…') && derivedTitle.startsWith(current.slice(0, -1))) return derivedTitle;
528
+ return current;
523
529
  }
524
530
 
525
531
  function sanitizeAutoTitle(value) {
526
- const title = sanitizeAssistantVisibleText(value)
527
- .replace(/@file:[^\s]+/g, ' ')
528
- .replace(/@(context|compile-log)\b/g, ' ')
529
- .replace(/\s+/g, ' ')
530
- .trim();
531
- if (!title) {
532
- return '';
533
- }
534
- return title.length > SESSION_AUTO_TITLE_CHARS
535
- ? `${title.slice(0, SESSION_AUTO_TITLE_CHARS - 1)}…`
536
- : title;
532
+ return SessionTitle.fromPrompt(value, { sanitize: sanitizeAssistantVisibleText, maxChars: SESSION_AUTO_TITLE_CHARS });
537
533
  }
538
534
 
539
535
  function isDisplayableSession(session) {
@@ -0,0 +1,120 @@
1
+ (function initSessionTitle(root, factory) {
2
+ if (typeof module === 'object' && module.exports) {
3
+ module.exports = factory();
4
+ } else {
5
+ root.CodexOverleafModuleRegistry.define('SessionTitle', [], factory);
6
+ }
7
+ })(typeof globalThis !== 'undefined' ? globalThis : window, function sessionTitleFactory() {
8
+ 'use strict';
9
+
10
+ const INPUT_LIMIT = 12000;
11
+ const STORAGE_LIMIT = 80;
12
+ const segmenter = typeof Intl !== 'undefined' && typeof Intl.Segmenter === 'function'
13
+ ? new Intl.Segmenter(undefined, { granularity: 'grapheme' }) : null;
14
+ const FILE_TOKEN = /@file:(?:"([^"]+)"|'([^']+)'|([^\s]+))/g;
15
+
16
+ function derive(runs, task, options = {}) {
17
+ let fallback = '';
18
+ for (const run of Array.isArray(runs) ? runs : []) {
19
+ const candidate = fromPrompt(run?.task, options);
20
+ fallback ||= candidate;
21
+ if (candidate && !isGeneric(candidate)) return candidate;
22
+ }
23
+ const draft = fromPrompt(task, options);
24
+ return draft && !isGeneric(draft) ? draft : fallback || draft;
25
+ }
26
+
27
+ function fromPrompt(value, { sanitize = text => text, maxChars = 24 } = {}) {
28
+ if (typeof value !== 'string' || !value.trim()) return '';
29
+ const source = String(sanitize(value.slice(0, INPUT_LIMIT)) || '');
30
+ let fence = null;
31
+ let fallback = '';
32
+ for (const rawLine of source.split(/\r?\n/)) {
33
+ const line = rawLine.trim();
34
+ const marker = /^(`{3,}|~{3,})(.*)$/.exec(line);
35
+ if (marker) {
36
+ if (!fence) {
37
+ fence = marker[1];
38
+ const language = marker[2].trim().match(/^[A-Za-z0-9_+-]{1,24}\b/)?.[0] || 'code';
39
+ fallback ||= `${language} snippet`;
40
+ } else if (marker[1][0] === fence[0] && marker[1].length >= fence.length && !marker[2].trim()) {
41
+ fence = null;
42
+ }
43
+ continue;
44
+ }
45
+ if (fence || !line || /^>/.test(line)) continue;
46
+ const withoutContext = line.replace(FILE_TOKEN, ' ').replace(/@(context|compile-log)\b/g, ' ').trim();
47
+ const cleaned = cleanLine(line);
48
+ if (!withoutContext || /^https?:\/\/\S+$/i.test(line)) {
49
+ fallback ||= cleaned;
50
+ continue;
51
+ }
52
+ for (const sentence of cleaned.split(/[\u3002\uff01\uff1f]+/u)) {
53
+ const candidate = sentence.trim().replace(/[,;:\uff0c\uff1b\uff1a]+$/u, '').trim();
54
+ if (!/[\p{L}\p{N}]/u.test(candidate) || isScaffolding(candidate)) continue;
55
+ fallback ||= candidate;
56
+ if (!isGeneric(candidate)) return shorten(candidate, maxChars);
57
+ }
58
+ }
59
+ return shorten(fallback, maxChars);
60
+ }
61
+
62
+ function cleanLine(value) {
63
+ return value
64
+ .replace(/^#{1,6}\s+/, '')
65
+ .replace(/^(?:[-*+]\s+|\d+\.\s+|\d+[)\u3001]\s*)/, '')
66
+ .replace(FILE_TOKEN, (_match, quoted, singleQuoted, bare) =>
67
+ String(quoted || singleQuoted || bare || '').replace(/\\/g, '/').split('/').pop())
68
+ .replace(/@(context|compile-log)\b/g, ' ')
69
+ .replace(/!?\[([^\]]*)\]\([^\n)]*\)/g, '$1')
70
+ .replace(/`([^`]+)`/g, '$1')
71
+ .replace(/\*\*([^*]+)\*\*/g, '$1')
72
+ .replace(/https?:\/\/\S+/gi, address => {
73
+ try { return new URL(address).hostname; } catch (_error) { return ''; }
74
+ })
75
+ .replace(/\[(?:REDACTED_SECRET|local path(?::\d+)?)\]/gi, ' ')
76
+ .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '')
77
+ .replace(/\s+/g, ' ').trim()
78
+ .replace(/^(?:\u8bf7(?:\u4f60)?\u5e2e\u6211|\u8bf7\u4f60|\u5e2e\u6211|\u9ebb\u70e6\u4f60(?:\u5e2e\u6211)?|\u9ebb\u70e6\u5e2e\u6211|please\s+)\s*/i, '');
79
+ }
80
+
81
+ function isScaffolding(value) {
82
+ return /^(?:my request(?: for [^:]+)?|request|task|context|files(?: mentioned by [^:]+)?|attachments|\u6211\u7684\u8bf7\u6c42|\u9700\u6c42|\u4efb\u52a1|\u4e0a\u4e0b\u6587|\u9644\u4ef6|\u6587\u4ef6)$/i.test(value);
83
+ }
84
+
85
+ function isGeneric(value) {
86
+ const text = String(value || '').trim().replace(/[\s.!?\u3002\uff1f\uff01\u2026]+$/u, '');
87
+ if (!text || Array.from(text).length < 2) return true;
88
+ return /^(?:ok(?:ay)?|yes|no|go|done|continue|proceed|thanks?|thank you|hello|hi|test|new task|untitled task|\u597d(?:\u7684)?|\u53ef\u4ee5|\u7ee7\u7eed|\u786e\u8ba4|\u786e\u5b9a|\u6536\u5230|\u8c22\u8c22|\u6d4b\u8bd5|\u6267\u884c|\u662f\u7684|\u4f60\u597d)$/i.test(text)
89
+ || /^[A-Za-z0-9_+-]{1,24} snippet$/i.test(text)
90
+ || /^[^\s]+\.(?:tex|bib|md|txt|pdf|svg|png|jpe?g|webp)(?::\d+)?$/i.test(text)
91
+ || /^(?:[A-Za-z0-9-]+\.)+[A-Za-z]{2,}$/i.test(text);
92
+ }
93
+
94
+ function shorten(value, maxChars) {
95
+ const title = String(value || '').trim();
96
+ if (!title) return '';
97
+ const chars = Number.isFinite(maxChars) && maxChars > 0 ? Math.min(40, Math.floor(maxChars)) : 24;
98
+ const maxUnits = chars * 2;
99
+ const glyphs = segmenter
100
+ ? Array.from(segmenter.segment(title), item => item.segment) : Array.from(title);
101
+ const width = glyph => /[^\u0000-\u00ff]/u.test(glyph) ? 2 : 1;
102
+ if (title.length <= STORAGE_LIMIT && glyphs.reduce((total, glyph) => total + width(glyph), 0) <= maxUnits) return title;
103
+ let prefix = '';
104
+ let units = 0;
105
+ let index = 0;
106
+ for (; index < glyphs.length; index++) {
107
+ const glyph = glyphs[index];
108
+ if (units + width(glyph) > maxUnits - 2 || prefix.length + glyph.length > STORAGE_LIMIT - 1) break;
109
+ prefix += glyph;
110
+ units += width(glyph);
111
+ }
112
+ const boundary = prefix.lastIndexOf(' ');
113
+ if (/[A-Za-z0-9]$/.test(prefix) && /^[A-Za-z0-9]/.test(glyphs[index] || '') && boundary >= prefix.length * 0.6) {
114
+ prefix = prefix.slice(0, boundary);
115
+ }
116
+ return prefix.trimEnd().replace(/[,;:\uff0c\uff1b\uff1a]+$/u, '') + '\u2026';
117
+ }
118
+
119
+ return Object.freeze({ derive, fromPrompt, isGeneric });
120
+ });
@@ -0,0 +1,32 @@
1
+ (function (root, factory) {
2
+ const api = factory();
3
+ if (typeof module === 'object' && module.exports) module.exports = api;
4
+ if (root) root.CodexOverleafUpdateRuntimeIdentity = api;
5
+ })(typeof globalThis !== 'undefined' ? globalThis : this, function () {
6
+ 'use strict';
7
+
8
+ function version(value) { return /^\d+\.\d+\.\d+$/.test(String(value || '')) ? String(value) : ''; }
9
+ function compare(left, right) {
10
+ const a = left.split('.').map(Number);
11
+ const b = right.split('.').map(Number);
12
+ return a[0] - b[0] || a[1] - b[1] || a[2] - b[2];
13
+ }
14
+
15
+ function inspectInstalledRuntime(status = {}, options = {}) {
16
+ const result = { installedVersion: version(status.activeVersion), nativeVersion: version(status.runtimeVersion),
17
+ extensionVersion: version(options.extensionVersion), runtimeVersion: version(options.runtimeVersion), state: 'unknown' };
18
+ if (!status.managed || !result.installedVersion || !result.nativeVersion ||
19
+ !result.extensionVersion || !result.runtimeVersion) return result;
20
+ if (['authorized', 'bound'].includes(status.authorization?.state) ||
21
+ ['staged', 'activation_pending', 'applying', 'awaiting_health'].includes(status.transaction?.state)) {
22
+ return { ...result, state: 'transaction_active' };
23
+ }
24
+ if (status.installedAligned !== true) return { ...result, state: 'repair_required' };
25
+ const loaded = [result.nativeVersion, result.extensionVersion, result.runtimeVersion];
26
+ if (loaded.every(value => value === result.installedVersion)) return { ...result, state: 'aligned' };
27
+ return { ...result, state: loaded.every(value => compare(result.installedVersion, value) >= 0)
28
+ ? 'reload_required' : 'repair_required' };
29
+ }
30
+
31
+ return Object.freeze({ inspectInstalledRuntime });
32
+ });
@@ -170,14 +170,14 @@
170
170
  }
171
171
 
172
172
  .codex-provider-dialog-root {
173
- --provider-bg: var(--provider-panel-bg, #151716);
173
+ --provider-bg: #151716;
174
174
  --provider-surface: #1c1f1d;
175
175
  --provider-surface-raised: #222622;
176
176
  --provider-line: rgba(255, 255, 255, 0.12);
177
177
  --provider-muted: #a0a59f;
178
178
  --provider-accent: #79d58a;
179
179
  --provider-danger: #ef8176;
180
- --provider-text: var(--provider-panel-color, #eef0ec);
180
+ --provider-text: #eef0ec;
181
181
  --provider-field-label: #dfe3dd;
182
182
  --provider-control-bg: rgba(255, 255, 255, 0.035);
183
183
  --provider-hover: rgba(255, 255, 255, 0.07);
@@ -191,6 +191,14 @@
191
191
  position: fixed;
192
192
  inset: 0;
193
193
  z-index: 2147483600;
194
+ margin: 0;
195
+ padding: 0;
196
+ border: 0;
197
+ width: 100vw;
198
+ max-width: none;
199
+ height: 100vh;
200
+ max-height: none;
201
+ background: transparent;
194
202
  color: var(--provider-text);
195
203
  font-family: "Aptos", "SF Pro Text", sans-serif;
196
204
  }
@@ -217,6 +225,7 @@
217
225
  }
218
226
 
219
227
  .codex-provider-dialog-root[hidden] { display: none !important; }
228
+ .codex-provider-dialog-root::backdrop { background: transparent; }
220
229
 
221
230
  .codex-provider-dialog-backdrop {
222
231
  position: absolute;
@@ -1572,7 +1581,7 @@
1572
1581
 
1573
1582
  #codex-overleaf-panel .codex-set-row-help {
1574
1583
  margin: 0;
1575
- color: var(--tl-fg-4);
1584
+ color: var(--tl-fg-3);
1576
1585
  font-size: 11px;
1577
1586
  line-height: 1.45;
1578
1587
  }
@@ -1801,9 +1810,15 @@
1801
1810
  box-sizing: border-box;
1802
1811
  width: var(--codex-overleaf-panel-width);
1803
1812
  height: 100vh;
1813
+ max-width: none;
1814
+ max-height: none;
1815
+ margin: 0;
1816
+ border: 0;
1817
+ color: inherit;
1804
1818
  padding: 14px;
1805
1819
  background: rgba(12, 12, 12, 0.68);
1806
1820
  }
1821
+ #codex-overleaf-panel .codex-plugin-confirm::backdrop { background: transparent; }
1807
1822
 
1808
1823
  #codex-overleaf-panel .codex-plugin-confirm-card {
1809
1824
  width: 100%;
@@ -2155,7 +2170,7 @@
2155
2170
  padding: 4px 10px;
2156
2171
  font-size: 11px;
2157
2172
  cursor: pointer;
2158
- box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35);
2173
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
2159
2174
  animation: tl-fade-in 120ms ease-out;
2160
2175
  }
2161
2176
 
@@ -2170,6 +2185,32 @@
2170
2185
  border-color: var(--tl-border-strong);
2171
2186
  }
2172
2187
 
2188
+ /* Let the browser anchor backscroll while the reader is detached. */
2189
+ #codex-overleaf-panel [data-log][data-log-auto-follow="false"],
2190
+ #codex-overleaf-panel [data-main][data-log-auto-follow="false"] {
2191
+ overflow-anchor: auto;
2192
+ }
2193
+
2194
+ #codex-overleaf-panel :is(.run-copy-action, .run-fork-action, .tl-jump-latest, .run-final-answer__recovery-action):focus-visible {
2195
+ outline: 2px solid var(--tl-accent);
2196
+ outline-offset: 2px;
2197
+ }
2198
+
2199
+ #codex-overleaf-panel :is(input, textarea)::placeholder {
2200
+ color: var(--tl-fg-3);
2201
+ opacity: 1;
2202
+ }
2203
+
2204
+ .codex-provider-dialog-root .codex-provider-field :is(input, textarea)::placeholder {
2205
+ color: var(--provider-muted);
2206
+ opacity: 1;
2207
+ }
2208
+
2209
+ .codex-provider-dialog-root :is(button, input, textarea, select):focus-visible {
2210
+ outline: 2px solid var(--provider-accent);
2211
+ outline-offset: 2px;
2212
+ }
2213
+
2173
2214
  /* Active-session bar: the title on the left, rename + delete on the right, so
2174
2215
  the session you are working in can be renamed/deleted in place. */
2175
2216
  #codex-overleaf-panel .codex-thread-title {
@@ -3069,13 +3110,13 @@
3069
3110
  }
3070
3111
 
3071
3112
  #codex-overleaf-panel .run-final-answer .run-system-label {
3072
- color: var(--tl-accent);
3113
+ color: var(--tl-fg-3);
3073
3114
  font-weight: 650;
3074
3115
  letter-spacing: 0.01em;
3075
3116
  }
3076
3117
 
3077
3118
  #codex-overleaf-panel .run-completion-report[data-status="failed"] .run-system-label[data-system-label="conclusion"] {
3078
- color: var(--tl-fail);
3119
+ color: var(--tl-fg-3);
3079
3120
  }
3080
3121
 
3081
3122
  #codex-overleaf-panel .run-final-answer .run-change-summary {
@@ -3164,9 +3205,8 @@
3164
3205
  }
3165
3206
 
3166
3207
  #codex-overleaf-panel .run-final-answer__meta {
3167
- margin: 10px 0 0;
3168
- padding: 8px 0 0;
3169
- border-top: 1px solid var(--tl-border);
3208
+ margin: 14px 0 0;
3209
+ padding: 0;
3170
3210
  display: grid;
3171
3211
  grid-template-columns: max-content 1fr;
3172
3212
  column-gap: 12px;
@@ -3190,18 +3230,15 @@
3190
3230
  }
3191
3231
 
3192
3232
  #codex-overleaf-panel .run-completion-report[data-status="failed"] {
3193
- color: var(--tl-fail);
3233
+ color: var(--tl-fg-2);
3194
3234
  }
3195
3235
 
3196
- /* .run-final-answer resets color to fg-1 for normal reports; a failed report's
3197
- conclusion must keep the alert color or failure reads like success. */
3236
+ /* Failure severity belongs to the status header and compact notice. */
3198
3237
  #codex-overleaf-panel .run-completion-report[data-status="failed"] .run-final-answer {
3199
- color: var(--tl-fail);
3238
+ color: var(--tl-fg-2);
3200
3239
  }
3201
3240
 
3202
- /* Failed-status reports keep their alert color for the conclusion, but the
3203
- meta block stays muted so it still reads as system info rather than amplifying
3204
- the alert. */
3241
+ /* Keep system metadata subordinate to the answer in both themes. */
3205
3242
  #codex-overleaf-panel .run-completion-report[data-status="failed"] .run-final-answer__meta,
3206
3243
  #codex-overleaf-panel .run-completion-report[data-status="failed"] .run-final-answer__meta-value {
3207
3244
  color: var(--tl-fg-3);
@@ -3237,6 +3274,43 @@
3237
3274
  opacity: 0.6;
3238
3275
  }
3239
3276
 
3277
+ #codex-overleaf-panel .run-feedback {
3278
+ display: grid;
3279
+ gap: 5px;
3280
+ margin: 12px 0 0;
3281
+ padding: 10px 12px;
3282
+ border-radius: 8px;
3283
+ background: var(--tl-surface-2);
3284
+ color: var(--tl-fg-2);
3285
+ font-size: 12px;
3286
+ line-height: 1.5;
3287
+ overflow-wrap: anywhere;
3288
+ }
3289
+
3290
+ #codex-overleaf-panel .run-feedback p { margin: 0; }
3291
+ #codex-overleaf-panel .run-feedback__title { color: var(--tl-fail); font-weight: 650; }
3292
+ #codex-overleaf-panel .run-feedback[data-tone="warning"] .run-feedback__title { color: var(--tl-review); }
3293
+ #codex-overleaf-panel .run-feedback .run-final-answer__recovery-action {
3294
+ justify-self: start;
3295
+ margin: 3px 0 0;
3296
+ padding: 3px 0;
3297
+ border: 0;
3298
+ background: transparent;
3299
+ color: var(--tl-accent);
3300
+ font-weight: 600;
3301
+ }
3302
+
3303
+ #codex-overleaf-panel :is(.run-copy-action, .run-fork-action) {
3304
+ color: var(--tl-fg-3);
3305
+ }
3306
+
3307
+ @media (prefers-reduced-motion: reduce) {
3308
+ #codex-overleaf-panel :is(.tl-jump-latest, .run-copy-action, .run-fork-action, .run-final-answer__recovery-action) {
3309
+ animation: none;
3310
+ transition: none;
3311
+ }
3312
+ }
3313
+
3240
3314
  #codex-overleaf-panel .codex-probe-line {
3241
3315
  flex: 0 0 auto;
3242
3316
  min-height: 18px;
@@ -712,6 +712,22 @@ function getMirrorStatus(projectId, options = {}) {
712
712
  };
713
713
  }
714
714
 
715
+ function normalizeObservedPatchTime(value, fallback) {
716
+ const candidate = value == null ? fallback : value;
717
+ const timestamp = typeof candidate === 'number'
718
+ ? candidate
719
+ : typeof candidate === 'string' ? Date.parse(candidate) : NaN;
720
+ return Number.isFinite(timestamp) && Math.abs(timestamp) <= 8640000000000000
721
+ ? new Date(timestamp).toISOString()
722
+ : '';
723
+ }
724
+
725
+ function isFreshOtObservation(freshness) {
726
+ const timestamp = Date.parse(freshness?.observedAt ?? freshness?.lastPatchAt ?? '');
727
+ const ageMs = Date.now() - timestamp;
728
+ return Number.isFinite(timestamp) && ageMs >= 0 && ageMs <= 30000;
729
+ }
730
+
715
731
  function buildOtStatusFields(baseline = {}, trusted) {
716
732
  const metadata = {
717
733
  lastOtPatchAt: baseline.lastOtPatchAt || '',
@@ -727,13 +743,15 @@ function buildOtStatusFields(baseline = {}, trusted) {
727
743
  }
728
744
  const textFiles = (baseline.files || []).filter(file => file?.kind === 'text');
729
745
  const freshFiles = textFiles
730
- .filter(file => file.freshness?.source === 'ot' && file.freshness?.state === 'fresh')
746
+ .filter(file => file.freshness?.source === 'ot' && file.freshness?.state === 'fresh'
747
+ && isFreshOtObservation(file.freshness))
731
748
  .map(file => ({
732
749
  path: file.path,
733
750
  source: file.freshness.source,
734
751
  state: file.freshness.state,
735
752
  lastFullSyncAt: file.freshness.lastFullSyncAt || '',
736
753
  lastPatchAt: file.freshness.lastPatchAt || '',
754
+ observedAt: file.freshness.observedAt ?? file.freshness.lastPatchAt ?? '',
737
755
  observedVersion: file.freshness.observedVersion ?? null
738
756
  }))
739
757
  .sort((left, right) => left.path.localeCompare(right.path));
@@ -935,6 +953,7 @@ async function patchMirrorFiles({ projectId, files, rootDir, source = 'ot' }) {
935
953
  state: 'fresh',
936
954
  lastFullSyncAt: baseline.lastFullSyncAt || '',
937
955
  lastPatchAt: patchBatchAt,
956
+ observedAt: normalizeObservedPatchTime(patch.observedAt, patchBatchAt),
938
957
  observedVersion: patch.observedVersion ?? null
939
958
  }
940
959
  };
@@ -0,0 +1,177 @@
1
+ 'use strict';
2
+
3
+ const http = require('node:http');
4
+ const https = require('node:https');
5
+ const tls = require('node:tls');
6
+ const net = require('node:net');
7
+ const { Readable } = require('node:stream');
8
+ const { execFileSync } = require('node:child_process');
9
+ const { updateError } = require('./updateTrust');
10
+ let systemCache = { platform: '', at: 0, text: '' };
11
+
12
+ function proxyError(code, message, retryable = false) {
13
+ return Object.assign(updateError(code, message), { retryable });
14
+ }
15
+
16
+ function bypassProxy(url, values = '') {
17
+ const host = url.hostname.toLowerCase();
18
+ const port = url.port || (url.protocol === 'https:' ? '443' : '80');
19
+ return String(values).split(/[,;\s]+/).some(raw => {
20
+ if (!raw) return false;
21
+ if (raw === '*') return true;
22
+ if (raw === '<local>') return !host.includes('.');
23
+ const match = raw.toLowerCase().match(/^(.*?)(?::(\d+))?$/);
24
+ if (match[2] && match[2] !== port) return false;
25
+ const domain = match[1].replace(/^\*?\./, '');
26
+ return host === domain || host.endsWith('.' + domain);
27
+ });
28
+ }
29
+
30
+ function readSystemProxy(platform) {
31
+ if (systemCache.platform === platform && Date.now() - systemCache.at < 30000) return systemCache.text;
32
+ let text = '';
33
+ try {
34
+ const command = platform === 'darwin' ? '/usr/sbin/scutil' : 'reg.exe';
35
+ const args = platform === 'darwin' ? ['--proxy']
36
+ : ['query', 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings'];
37
+ text = execFileSync(command, args, { encoding: 'utf8', timeout: 1000, maxBuffer: 32768,
38
+ windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'] });
39
+ } catch (_error) { /* environment proxies and direct access remain available */ }
40
+ systemCache = { platform, at: Date.now(), text };
41
+ return text;
42
+ }
43
+
44
+ function resolveReleaseProxy(value, options = {}) {
45
+ const url = new URL(value);
46
+ const env = options.env || process.env;
47
+ if (bypassProxy(url, env.NO_PROXY || env.no_proxy)) return null;
48
+ const protocol = url.protocol === 'https:' ? 'HTTPS' : 'HTTP';
49
+ let configured = env[protocol + '_PROXY'] || env[protocol.toLowerCase() + '_proxy'] || env.ALL_PROXY || env.all_proxy;
50
+ let source = 'environment';
51
+ const platform = options.platform || process.platform;
52
+ if (!configured && ['darwin', 'win32'].includes(platform)) {
53
+ source = 'system';
54
+ const text = (options.systemProxy || readSystemProxy)(platform);
55
+ if (platform === 'darwin') {
56
+ const field = key => text.match(new RegExp('(?:^|\\n)\\s*' + key + '\\s*:\\s*([^\\n]+)'))?.[1]?.trim() || '';
57
+ if (field(protocol + 'Enable') === '1') {
58
+ const host = field(protocol + 'Proxy');
59
+ const port = Number(field(protocol + 'Port'));
60
+ if (host && Number.isInteger(port) && port > 0 && port <= 65535) {
61
+ configured = 'http://' + (host.includes(':') ? '[' + host + ']' : host) + ':' + port;
62
+ }
63
+ }
64
+ const exceptions = text.match(/ExceptionsList\s*:[\s\S]*?\n\s*}/)?.[0] || '';
65
+ const values = Array.from(exceptions.matchAll(/\d+\s*:\s*([^\n]+)/g), match => match[1].trim()).join(',');
66
+ if (bypassProxy(url, values)) return null;
67
+ } else if (/ProxyEnable\s+REG_DWORD\s+0x1\b/i.test(text)) {
68
+ const server = text.match(/ProxyServer\s+REG_SZ\s+([^\r\n]+)/i)?.[1]?.trim() || '';
69
+ const exceptions = text.match(/ProxyOverride\s+REG_SZ\s+([^\r\n]+)/i)?.[1] || '';
70
+ if (bypassProxy(url, exceptions)) return null;
71
+ configured = server.includes('=')
72
+ ? server.split(';').find(part => part.toLowerCase().startsWith(protocol.toLowerCase() + '='))?.split('=').slice(1).join('=')
73
+ : server;
74
+ if (configured && !configured.includes('://')) configured = 'http://' + configured;
75
+ }
76
+ }
77
+ if (!configured) return null;
78
+ let proxy;
79
+ try { proxy = new URL(configured); } catch (_error) {
80
+ throw proxyError('update_proxy_invalid', 'The configured update proxy address is invalid.');
81
+ }
82
+ if (!['http:', 'https:'].includes(proxy.protocol)) {
83
+ throw proxyError('update_proxy_unsupported', 'The update proxy must use HTTP or HTTPS. SOCKS and PAC require an HTTP proxy endpoint.');
84
+ }
85
+ return { url: proxy.href, source };
86
+ }
87
+
88
+ function hostname(url) { return url.hostname.replace(/^\[|\]$/g, ''); }
89
+ function proxyHeaders(proxy) {
90
+ if (!proxy.username && !proxy.password) return {};
91
+ let credentials;
92
+ try { credentials = decodeURIComponent(proxy.username) + ':' + decodeURIComponent(proxy.password); }
93
+ catch (_error) { throw proxyError('update_proxy_invalid', 'The configured proxy credentials are malformed.'); }
94
+ return { 'Proxy-Authorization': 'Basic ' + Buffer.from(credentials).toString('base64') };
95
+ }
96
+
97
+ function tunnel(target, proxy, signal) {
98
+ return new Promise((resolve, reject) => {
99
+ const authority = target.hostname + ':' + (target.port || '443');
100
+ const client = proxy.protocol === 'https:' ? https : http;
101
+ const request = client.request({ protocol: proxy.protocol, hostname: hostname(proxy), port: proxy.port,
102
+ method: 'CONNECT', path: authority, headers: { Host: authority, ...proxyHeaders(proxy) }, agent: false, signal });
103
+ request.once('error', reject);
104
+ request.once('connect', (response, socket, head) => {
105
+ if (response.statusCode !== 200) {
106
+ socket.destroy();
107
+ reject(proxyError(response.statusCode === 407 ? 'update_proxy_auth_required' : 'update_proxy_failed',
108
+ 'The proxy refused the secure update connection (HTTP ' + response.statusCode + ').', response.statusCode >= 500));
109
+ return;
110
+ }
111
+ if (signal.aborted) { socket.destroy(); reject(signal.reason); return; }
112
+ if (head.length) socket.unshift(head);
113
+ const secure = tls.connect({ socket, host: hostname(target),
114
+ servername: net.isIP(hostname(target)) ? undefined : hostname(target), ALPNProtocols: ['http/1.1'] });
115
+ const abort = () => secure.destroy(signal.reason);
116
+ signal.addEventListener('abort', abort, { once: true });
117
+ secure.once('close', () => signal.removeEventListener('abort', abort));
118
+ secure.once('error', reject);
119
+ secure.once('secureConnect', () => resolve(secure));
120
+ });
121
+ request.end();
122
+ });
123
+ }
124
+
125
+ async function proxyRequest(target, proxy, init) {
126
+ let agent;
127
+ if (target.protocol === 'https:') {
128
+ const socket = await tunnel(target, proxy, init.signal);
129
+ if (init.signal.aborted) { socket.destroy(); throw init.signal.reason; }
130
+ agent = new https.Agent({ keepAlive: false });
131
+ agent.createConnection = () => socket;
132
+ }
133
+ return new Promise((resolve, reject) => {
134
+ const headers = { ...Object.fromEntries(new Headers(init.headers || {})), Host: target.host };
135
+ const secure = target.protocol === 'https:';
136
+ const client = secure || proxy.protocol === 'https:' ? https : http;
137
+ const request = secure
138
+ ? client.request(target, { method: init.method, headers, agent, signal: init.signal })
139
+ : client.request({ protocol: proxy.protocol, hostname: hostname(proxy), port: proxy.port,
140
+ method: init.method, path: target.href, headers: { ...headers, ...proxyHeaders(proxy) }, agent: false, signal: init.signal });
141
+ request.once('error', error => { agent?.destroy(); reject(error); });
142
+ request.once('response', incoming => {
143
+ incoming.once('close', () => agent?.destroy());
144
+ const headers = new Headers();
145
+ for (let i = 0; i < incoming.rawHeaders.length; i += 2) headers.append(incoming.rawHeaders[i], incoming.rawHeaders[i + 1]);
146
+ const empty = init.method === 'HEAD' || [204, 205, 304].includes(incoming.statusCode);
147
+ if (empty) incoming.resume();
148
+ const response = new Response(empty ? null : Readable.toWeb(incoming),
149
+ { status: incoming.statusCode, statusText: incoming.statusMessage, headers });
150
+ Object.defineProperty(response, 'url', { value: target.href });
151
+ resolve(response);
152
+ });
153
+ request.end();
154
+ });
155
+ }
156
+
157
+ async function fetchRelease(url, init, options = {}) {
158
+ const proxy = resolveReleaseProxy(url, options);
159
+ if (!proxy) return globalThis.fetch(url, init);
160
+ let target = new URL(url);
161
+ for (let redirect = 0; redirect <= 5; redirect += 1) {
162
+ const response = await proxyRequest(target, new URL(proxy.url), init);
163
+ if (![301, 302, 303, 307, 308].includes(response.status)) return response;
164
+ const location = response.headers.get('location');
165
+ if (!location) return response;
166
+ if (response.body) await response.body.cancel();
167
+ const next = new URL(location, target);
168
+ if (!['http:', 'https:'].includes(next.protocol) || target.protocol === 'https:' && next.protocol !== 'https:' ||
169
+ options.allowedHosts && !options.allowedHosts.has(next.hostname)) {
170
+ throw proxyError('update_asset_redirect_forbidden', 'Release request redirected to an untrusted host.');
171
+ }
172
+ target = next;
173
+ }
174
+ throw proxyError('update_network_redirect_limit', 'The update request exceeded its redirect limit.');
175
+ }
176
+
177
+ module.exports = { fetchRelease, resolveReleaseProxy };