codex-overleaf-link 1.8.6 → 1.9.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 (72) hide show
  1. package/README.md +45 -21
  2. package/bin/codex-overleaf-link.mjs +58 -0
  3. package/extension/assets/icons/codex-overleaf-dialog-icon.png +0 -0
  4. package/extension/assets/icons/codex-overleaf-icon.png +0 -0
  5. package/extension/assets/icons/icon128.png +0 -0
  6. package/extension/assets/icons/icon16.png +0 -0
  7. package/extension/assets/icons/icon32.png +0 -0
  8. package/extension/assets/icons/icon48.png +0 -0
  9. package/extension/bootstrap/background.js +404 -0
  10. package/extension/bootstrap/manifest.template.json +51 -0
  11. package/extension/bootstrap/popup.css +26 -0
  12. package/extension/bootstrap/popup.html +38 -0
  13. package/extension/bootstrap/popup.js +44 -0
  14. package/extension/bootstrap/runtimeContext.js +2 -0
  15. package/extension/runtime-manifest.json +60 -0
  16. package/extension/src/background.js +532 -0
  17. package/extension/src/content/applyResultFormatters.js +387 -0
  18. package/extension/src/content/composerAttachments.js +522 -0
  19. package/extension/src/content/composerPanel.js +254 -0
  20. package/extension/src/content/contentRuntime.js +8895 -0
  21. package/extension/src/content/contextTray.js +686 -0
  22. package/extension/src/content/diagnosticsController.js +659 -0
  23. package/extension/src/content/diagnosticsPanel.js +342 -0
  24. package/extension/src/content/diffReviewPanel.js +797 -0
  25. package/extension/src/content/localSkillsPanel.js +280 -0
  26. package/extension/src/content/markdownText.js +606 -0
  27. package/extension/src/content/mirrorHealth.js +92 -0
  28. package/extension/src/content/modelPicker.js +489 -0
  29. package/extension/src/content/nativeChannel.js +62 -0
  30. package/extension/src/content/otWarmMirror.js +964 -0
  31. package/extension/src/content/otWarmMirrorController.js +174 -0
  32. package/extension/src/content/panelMaintenance.js +367 -0
  33. package/extension/src/content/panelRenderer.js +253 -0
  34. package/extension/src/content/recentProjects.js +1063 -0
  35. package/extension/src/content/reviewHunks.js +247 -0
  36. package/extension/src/content/runController.js +242 -0
  37. package/extension/src/content/runTimelineView.js +1126 -0
  38. package/extension/src/content/sessionManager.js +487 -0
  39. package/extension/src/content/sessionPanel.js +263 -0
  40. package/extension/src/content/settingsPanel.js +527 -0
  41. package/extension/src/content/themeController.js +91 -0
  42. package/extension/src/content/updateIdle.js +67 -0
  43. package/extension/src/content/writebackController.js +300 -0
  44. package/extension/src/content/writebackOrchestrator.js +850 -0
  45. package/extension/src/contentScript.js +8 -0
  46. package/extension/src/page/compileBridge.js +460 -0
  47. package/extension/src/page/overleafCapabilities.js +100 -0
  48. package/extension/src/page/overleafEditor.js +282 -0
  49. package/extension/src/page/overleafProjectSnapshot.js +234 -0
  50. package/extension/src/page/overleafRealtimeObserver.js +491 -0
  51. package/extension/src/page/pageBridgeCapability.js +71 -0
  52. package/extension/src/page/snapshotRouter.js +948 -0
  53. package/extension/src/page/trackedChangesLifecycle.js +1506 -0
  54. package/extension/src/page/treeOperations.js +1584 -0
  55. package/extension/src/page/writeGuard.js +142 -0
  56. package/extension/src/page/writebackRouter.js +1934 -0
  57. package/extension/src/pageBridge.js +2224 -0
  58. package/extension/src/popup.js +247 -0
  59. package/extension/src/shared/compatibility.js +1 -1
  60. package/extension/styles/panel.css +4048 -0
  61. package/native-host/src/index.js +9 -2
  62. package/native-host/src/managedInstall.js +393 -0
  63. package/native-host/src/managedLauncherRuntime.js +71 -0
  64. package/native-host/src/nativeHostPlatform.js +16 -0
  65. package/native-host/src/runtimeInstaller.js +6 -1
  66. package/native-host/src/taskRunnerRuntime.js +8 -0
  67. package/native-host/src/updateArchive.js +253 -0
  68. package/native-host/src/updateManager.js +548 -0
  69. package/native-host/src/updateTrust.js +156 -0
  70. package/package.json +11 -2
  71. package/scripts/install-managed.mjs +64 -0
  72. package/scripts/uninstall-managed.mjs +48 -0
@@ -0,0 +1,487 @@
1
+ (function initCodexOverleafSessionManager() {
2
+ 'use strict';
3
+
4
+ const SessionPanel = window.CodexOverleafSessionPanel;
5
+ const {
6
+ createSession,
7
+ deleteSession,
8
+ setActiveSession,
9
+ getActiveSession,
10
+ isDisplayableSession,
11
+ normalizePanelState,
12
+ deriveSessionTitle
13
+ } = window.CodexOverleafSessionState;
14
+
15
+ // Session manager — the session lifecycle + list/rename/delete surface
16
+ // carved out of contentRuntime.js (v1.4.7 structural-debt phase 3). Code
17
+ // moved verbatim except that mutable runtime state is read through
18
+ // getState()/getPanel()/getSessionPanelInstance() and written back through
19
+ // setState() (sessions are immutable-update: every mutation rebuilds state
20
+ // via the shared sessionState helpers and hands it back to the runtime).
21
+ function create(deps = {}) {
22
+ const {
23
+ tr,
24
+ showPluginConfirm,
25
+ showPluginToast,
26
+ sendBackgroundNative,
27
+ saveState,
28
+ saveStateSoon,
29
+ readPanelInputs,
30
+ applyStateToPanel,
31
+ getPanel,
32
+ getState,
33
+ setState,
34
+ getSessionPanelInstance,
35
+ formatSessionTime
36
+ } = deps;
37
+
38
+ function applySessionLabel() {
39
+ const trigger = getPanel().querySelector('[data-session-menu-trigger]');
40
+ if (trigger) {
41
+ trigger.title = tr('sessionMenuOpen');
42
+ trigger.setAttribute('aria-label', tr('sessionMenuOpen'));
43
+ }
44
+ const textNode = getPanel().querySelector('[data-session-label-text]');
45
+ const deleteButton = getPanel().querySelector('[data-session-delete]');
46
+ const renameButton = getPanel().querySelector('[data-session-rename]');
47
+ const renameInput = getPanel().querySelector('[data-session-rename-input]');
48
+ const active = getActiveSession(getState());
49
+ if (textNode) {
50
+ // Always show a title — fall back to the New Session placeholder for an
51
+ // empty active session (which has no list row), so the session you are in
52
+ // is never blank and unmanageable from the header.
53
+ textNode.textContent = active && isDisplayableSession(active)
54
+ ? getSessionDisplayTitle(active)
55
+ : tr('newSessionFallback');
56
+ }
57
+ if (deleteButton) {
58
+ const running = active ? isSessionRunning(active) : false;
59
+ // Deleting the sole empty session is a documented no-op (it would just
60
+ // mint another empty), so disable it rather than leave a dead click.
61
+ const soleEmpty = Boolean(active) && !isDisplayableSession(active) && (getState()?.sessions || []).length <= 1;
62
+ deleteButton.disabled = !active || running || soleEmpty;
63
+ deleteButton.title = running ? tr('deleteRunningSession') : tr('deleteSession');
64
+ deleteButton.setAttribute('aria-label', running ? tr('deleteRunningSession') : tr('deleteSession'));
65
+ }
66
+ if (renameButton) {
67
+ renameButton.title = tr('renameSession');
68
+ renameButton.setAttribute('aria-label', tr('renameSession'));
69
+ }
70
+ renameInput?.setAttribute('aria-label', tr('renameSession'));
71
+ }
72
+
73
+ // Wire the active-session header bar once (rename + delete act on the active
74
+ // session, reusing the same paths as the per-row controls).
75
+ function bindActiveSessionHeader() {
76
+ getPanel().querySelector('[data-session-delete]')?.addEventListener('click', event => {
77
+ event.stopPropagation();
78
+ if (getState()?.activeSessionId) {
79
+ deleteSessionWithConfirm(getState().activeSessionId);
80
+ }
81
+ });
82
+ getPanel().querySelector('[data-session-rename]')?.addEventListener('click', event => {
83
+ event.stopPropagation();
84
+ beginActiveSessionRename();
85
+ });
86
+ getPanel().querySelector('[data-session-menu-trigger]')?.addEventListener('click', event => {
87
+ event.stopPropagation();
88
+ toggleSessionMenu();
89
+ });
90
+ // Close the dropdown on any click outside it (capture phase, so the
91
+ // panel's own click-stoppers don't swallow the dismiss — same pattern as
92
+ // the diagnostics menu) and on Escape.
93
+ document.addEventListener('click', event => {
94
+ const menu = getPanel()?.querySelector('[data-session-menu]');
95
+ if (!menu || menu.hidden) {
96
+ return;
97
+ }
98
+ const trigger = getPanel()?.querySelector('[data-session-menu-trigger]');
99
+ if (menu.contains(event.target) || trigger?.contains(event.target)) {
100
+ return;
101
+ }
102
+ closeSessionMenu();
103
+ }, true);
104
+ document.addEventListener('keydown', event => {
105
+ if (event.key === 'Escape') {
106
+ closeSessionMenu();
107
+ }
108
+ }, true);
109
+ }
110
+
111
+ // Header session dropdown: lists every saved session where the user works,
112
+ // switches on click, and offers New session — the embedded list below stays
113
+ // as the always-visible overview.
114
+ function toggleSessionMenu() {
115
+ const menu = getPanel()?.querySelector('[data-session-menu]');
116
+ if (!menu) {
117
+ return;
118
+ }
119
+ if (menu.hidden) {
120
+ renderSessionMenu(menu);
121
+ }
122
+ menu.hidden = !menu.hidden;
123
+ getPanel()?.querySelector('[data-session-menu-trigger]')
124
+ ?.setAttribute('aria-expanded', menu.hidden ? 'false' : 'true');
125
+ }
126
+
127
+ function closeSessionMenu() {
128
+ const menu = getPanel()?.querySelector('[data-session-menu]');
129
+ if (!menu || menu.hidden) {
130
+ return;
131
+ }
132
+ menu.hidden = true;
133
+ getPanel()?.querySelector('[data-session-menu-trigger]')?.setAttribute('aria-expanded', 'false');
134
+ }
135
+
136
+ function renderSessionMenu(menu) {
137
+ menu.replaceChildren();
138
+ const state = getState();
139
+ const sessions = (state?.sessions || [])
140
+ .filter(isDisplayableSession)
141
+ .slice()
142
+ .sort((a, b) => String(b.updatedAt || b.createdAt || '').localeCompare(String(a.updatedAt || a.createdAt || '')));
143
+ for (const session of sessions) {
144
+ const row = document.createElement('button');
145
+ row.type = 'button';
146
+ row.className = 'codex-session-menu-item';
147
+ row.dataset.active = session.id === state?.activeSessionId ? 'true' : 'false';
148
+ row.dataset.running = isSessionRunning(session) ? 'true' : 'false';
149
+ const title = document.createElement('span');
150
+ title.className = 'codex-session-menu-item-title';
151
+ title.textContent = getSessionDisplayTitle(session);
152
+ title.title = getSessionDisplayTitle(session);
153
+ const time = document.createElement('time');
154
+ time.className = 'codex-session-menu-item-time';
155
+ time.textContent = formatSessionTime(session.updatedAt || session.createdAt);
156
+ const absoluteTs = session.updatedAt || session.createdAt;
157
+ if (absoluteTs) {
158
+ time.title = new Date(absoluteTs).toLocaleString();
159
+ }
160
+ row.append(title, time);
161
+ row.addEventListener('click', event => {
162
+ event.stopPropagation();
163
+ closeSessionMenu();
164
+ if (session.id !== getState()?.activeSessionId) {
165
+ switchSession(session.id);
166
+ }
167
+ });
168
+ menu.append(row);
169
+ }
170
+ const create = document.createElement('button');
171
+ create.type = 'button';
172
+ create.className = 'codex-session-menu-new';
173
+ create.textContent = `+ ${tr('newSession')}`;
174
+ create.addEventListener('click', event => {
175
+ event.stopPropagation();
176
+ closeSessionMenu();
177
+ startNewSession();
178
+ });
179
+ menu.append(create);
180
+ }
181
+
182
+ // Inline-rename the active session from the header bar (mirrors the per-row
183
+ // rename: Enter / blur commits, Escape cancels).
184
+ function beginActiveSessionRename() {
185
+ const active = getActiveSession(getState());
186
+ const wrap = getPanel().querySelector('[data-session-label]');
187
+ const trigger = getPanel().querySelector('[data-session-menu-trigger]');
188
+ const textNode = getPanel().querySelector('[data-session-label-text]');
189
+ const input = getPanel().querySelector('[data-session-rename-input]');
190
+ if (!active || !wrap || !textNode || !input) {
191
+ return;
192
+ }
193
+ closeSessionMenu();
194
+ let settled = false;
195
+ wrap.dataset.editing = 'true';
196
+ // Seed the box from a real title only; an empty/auto session opens blank so
197
+ // accepting the default does not commit the placeholder as a manual title.
198
+ const seed = isDisplayableSession(active) ? getSessionDisplayTitle(active) : '';
199
+ input.value = seed;
200
+ input.hidden = false;
201
+ textNode.hidden = true;
202
+ if (trigger) {
203
+ trigger.hidden = true;
204
+ }
205
+ input.focus();
206
+ input.select();
207
+ const cleanup = () => {
208
+ input.removeEventListener('keydown', onKeydown);
209
+ input.removeEventListener('blur', onBlur);
210
+ };
211
+ const finish = commit => {
212
+ if (settled) {
213
+ return;
214
+ }
215
+ settled = true;
216
+ cleanup();
217
+ input.hidden = true;
218
+ textNode.hidden = false;
219
+ if (trigger) {
220
+ trigger.hidden = false;
221
+ }
222
+ delete wrap.dataset.editing;
223
+ // Treat an unchanged value (incl. a no-op blur) as a cancel so it never
224
+ // rewrites the title source.
225
+ if (commit && input.value.trim() !== seed.trim()) {
226
+ commitSessionRename(active.id, input.value);
227
+ }
228
+ };
229
+ const onKeydown = event => {
230
+ if (event.key === 'Enter') {
231
+ event.preventDefault();
232
+ finish(true);
233
+ } else if (event.key === 'Escape') {
234
+ event.preventDefault();
235
+ finish(false);
236
+ }
237
+ };
238
+ const onBlur = () => finish(true);
239
+ input.addEventListener('keydown', onKeydown);
240
+ input.addEventListener('blur', onBlur);
241
+ }
242
+
243
+ async function startNewSession() {
244
+ // Leaving a still-running session is allowed but never silent: the run
245
+ // keeps going in the background and lands in its own session.
246
+ const runningActive = getActiveSession(getState());
247
+ if (runningActive && isSessionRunning(runningActive)) {
248
+ const approved = await showPluginConfirm({
249
+ title: tr('sessionSwitchRunningTitle'),
250
+ message: tr('sessionSwitchRunningMessage'),
251
+ confirmLabel: tr('sessionSwitchRunningConfirm')
252
+ });
253
+ if (!approved) {
254
+ return;
255
+ }
256
+ }
257
+ readPanelInputs();
258
+ // Reuse an already-empty, idle active session instead of minting a ghost.
259
+ // Empty sessions are invisible in the list yet still consume the storage cap
260
+ // (slice(-20) / maxSessions), so spamming "New Session" could silently evict
261
+ // real history. Only mint when the current session holds real work.
262
+ const active = getActiveSession(getState());
263
+ if (active && !isDisplayableSession(active) && !isSessionRunning(active)) {
264
+ getPanel().querySelector('[data-task]')?.focus();
265
+ return;
266
+ }
267
+ // The 21st session silently and PERMANENTLY evicts the oldest one
268
+ // (slice(-20) + the storage retention diff physically deletes its
269
+ // record). Never let that happen without consent.
270
+ const currentSessions = getState().sessions || [];
271
+ if (currentSessions.length >= 20) {
272
+ const oldest = currentSessions[0];
273
+ const evictionApproved = await showPluginConfirm({
274
+ title: tr('sessionEvictTitle'),
275
+ message: tr('sessionEvictMessage', { title: String(oldest?.title || '').slice(0, 40) || '…' }),
276
+ confirmLabel: tr('sessionEvictConfirm'),
277
+ destructive: true
278
+ });
279
+ if (!evictionApproved) {
280
+ return;
281
+ }
282
+ }
283
+ const session = createSession({
284
+ mode: getState().mode,
285
+ model: getState().model,
286
+ reasoningEffort: getState().reasoningEffort,
287
+ speedTier: getState().speedTier,
288
+ requireReviewing: getState().requireReviewing
289
+ });
290
+ setState(normalizePanelState({
291
+ ...getState(),
292
+ sessions: [...(getState().sessions || []), session].slice(-20),
293
+ activeSessionId: session.id
294
+ }));
295
+ await saveState();
296
+ applyStateToPanel();
297
+ getPanel().querySelector('[data-task]')?.focus();
298
+ }
299
+
300
+ async function switchSession(sessionId) {
301
+ if (sessionId === getState().activeSessionId) {
302
+ return;
303
+ }
304
+ const runningActive = getActiveSession(getState());
305
+ if (runningActive && isSessionRunning(runningActive)) {
306
+ const approved = await showPluginConfirm({
307
+ title: tr('sessionSwitchRunningTitle'),
308
+ message: tr('sessionSwitchRunningMessage'),
309
+ confirmLabel: tr('sessionSwitchRunningConfirm')
310
+ });
311
+ if (!approved) {
312
+ return;
313
+ }
314
+ }
315
+ readPanelInputs();
316
+ setState(setActiveSession(getState(), sessionId));
317
+ await saveState();
318
+ applyStateToPanel();
319
+ }
320
+
321
+ async function deleteSessionWithConfirm(sessionId) {
322
+ const target = (getState().sessions || []).find(session => session.id === sessionId);
323
+ if (!target) {
324
+ return;
325
+ }
326
+ if (isSessionRunning(target)) {
327
+ showPluginToast(tr('deleteSessionRunningToast'), { status: 'warning' });
328
+ return;
329
+ }
330
+
331
+ const remaining = (getState().sessions || []).filter(session => session.id !== sessionId);
332
+
333
+ // An empty (non-displayable) session has no task/runs/history and no native
334
+ // thread, so deleting it is a lossless local action — skip the destructive
335
+ // modal. Deleting the sole empty session would just mint another empty, so
336
+ // it is a no-op.
337
+ if (!isDisplayableSession(target)) {
338
+ if (!remaining.length) {
339
+ return;
340
+ }
341
+ setState(deleteSession(getState(), sessionId));
342
+ await saveState();
343
+ applyStateToPanel();
344
+ showPluginToast(tr('deleteSessionDeletedToast'), { status: 'info' });
345
+ return;
346
+ }
347
+
348
+ // Deleting the only session is really a reset (deleteSession mints a fresh
349
+ // empty replacement), so use honest copy for that case.
350
+ const lastSession = remaining.length === 0;
351
+ const approved = await showPluginConfirm({
352
+ title: lastSession ? tr('resetSessionTitle') : tr('deleteSessionTitle'),
353
+ message: lastSession
354
+ ? tr('resetSessionMessage')
355
+ : [
356
+ getSessionDisplayTitle(target),
357
+ '',
358
+ tr('deleteSessionMessage')
359
+ ].join('\n'),
360
+ confirmLabel: lastSession ? tr('resetSessionConfirm') : tr('deleteSessionConfirm'),
361
+ cancelLabel: tr('confirmDefaultCancel'),
362
+ destructive: true
363
+ });
364
+ if (!approved) {
365
+ return;
366
+ }
367
+
368
+ setState(deleteSession(getState(), sessionId));
369
+ await saveState();
370
+ applyStateToPanel();
371
+
372
+ try {
373
+ const response = await sendBackgroundNative({
374
+ method: 'codex.history.clearPlugin',
375
+ params: {
376
+ sessionId,
377
+ threadId: target.codexThreadId || ''
378
+ }
379
+ });
380
+ if (!response?.ok) {
381
+ showPluginToast(tr('deleteSessionHistoryFailedToast', { message: response?.error?.message || 'native host did not return success' }), { status: 'warning', sticky: true });
382
+ } else if (response.result?.skipped) {
383
+ showPluginToast(tr('deleteSessionNoThreadToast'), { status: 'info' });
384
+ } else {
385
+ showPluginToast(tr('deleteSessionDoneToast'), { status: 'completed' });
386
+ }
387
+ } catch (error) {
388
+ showPluginToast(tr('deleteSessionHistoryFailedToast', { message: error.message }), { status: 'warning', sticky: true });
389
+ }
390
+ }
391
+ function renderSessionList(options = {}) {
392
+ // Omit showAll unless a caller explicitly sets it, so the getPanel() keeps its own
393
+ // expanded/collapsed getState() across re-renders (see sessionPanel.update).
394
+ const updateOptions = { pinnedSessionIds: getRunningSessionIds() };
395
+ if (options.showAll !== undefined) {
396
+ updateOptions.showAll = options.showAll;
397
+ }
398
+ SessionPanel.update(getSessionPanelInstance(), getState()?.sessions || [], getState()?.activeSessionId || null, updateOptions);
399
+ }
400
+
401
+ function getSessionDisplayTitle(session) {
402
+ return SessionPanel.getDisplayTitle(getSessionPanelInstance(), session);
403
+ }
404
+
405
+
406
+
407
+ async function commitSessionRename(sessionId, title) {
408
+ const session = findSessionById(sessionId);
409
+ if (!session) {
410
+ return;
411
+ }
412
+ const cleanTitle = String(title || '').replace(/\s+/g, ' ').trim();
413
+ const derived = deriveSessionTitle(session.runs, session.task);
414
+ // Only a value that is genuinely a custom title becomes a pinned manual
415
+ // title. An empty value, the localized New Session placeholder, or the
416
+ // auto-derived title itself all stay 'auto' — otherwise renaming an empty
417
+ // session would promote the placeholder into a real title (resurrecting the
418
+ // ghost the prune/reuse-guard eliminate) and a no-op blur would freeze an
419
+ // auto title.
420
+ const isCustom = Boolean(cleanTitle) && cleanTitle !== tr('newSessionFallback') && cleanTitle !== derived;
421
+ replaceSessionInState({
422
+ ...session,
423
+ title: isCustom ? cleanTitle : derived,
424
+ titleSource: isCustom ? 'manual' : 'auto',
425
+ updatedAt: new Date().toISOString()
426
+ });
427
+ await saveState();
428
+ applySessionLabel();
429
+ renderSessionList();
430
+ }
431
+ function getRunningSessionIds() {
432
+ return (getState().sessions || [])
433
+ .filter(isSessionRunning)
434
+ .map(session => session.id);
435
+ }
436
+
437
+ function isSessionRunning(session) {
438
+ return Boolean((session?.runs || []).some(run => run.status === 'running'));
439
+ }
440
+ function findSessionById(sessionId) {
441
+ if (!sessionId) {
442
+ return null;
443
+ }
444
+ return (getState().sessions || []).find(session => session.id === sessionId)
445
+ || (getState().session?.id === sessionId ? getState().session : null);
446
+ }
447
+
448
+ function replaceSessionInState(session) {
449
+ if (!session?.id) {
450
+ return;
451
+ }
452
+ setState(normalizePanelState({
453
+ ...getState(),
454
+ sessions: (getState().sessions || []).map(item => item.id === session.id ? session : item)
455
+ }));
456
+ }
457
+
458
+ function updateSessionById(sessionId, patch = {}) {
459
+ const session = findSessionById(sessionId);
460
+ if (!session) {
461
+ return;
462
+ }
463
+ replaceSessionInState({
464
+ ...session,
465
+ ...patch
466
+ });
467
+ }
468
+
469
+ return {
470
+ closeSessionMenu,
471
+ applySessionLabel,
472
+ bindActiveSessionHeader,
473
+ startNewSession,
474
+ switchSession,
475
+ deleteSessionWithConfirm,
476
+ renderSessionList,
477
+ commitSessionRename,
478
+ getRunningSessionIds,
479
+ isSessionRunning,
480
+ findSessionById,
481
+ replaceSessionInState,
482
+ updateSessionById
483
+ };
484
+ }
485
+
486
+ window.CodexOverleafSessionManager = { create };
487
+ })();