codex-overleaf-link 2.3.6 → 2.4.1

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 (52) hide show
  1. package/README.md +62 -34
  2. package/README.zh-CN.md +546 -0
  3. package/extension/bootstrap/manifest.template.json +1 -1
  4. package/extension/src/background.js +3 -0
  5. package/extension/src/content/activeTurnControl.js +1 -0
  6. package/extension/src/content/assetTransferBroker.js +2 -0
  7. package/extension/src/content/contentRuntime.js +122 -118
  8. package/extension/src/content/contextTray.js +4 -0
  9. package/extension/src/content/diagnosticsController.js +4 -0
  10. package/extension/src/content/diagnosticsPanel.js +128 -4
  11. package/extension/src/content/generated/content.bundle.js +1233 -550
  12. package/extension/src/content/generated/content.bundle.meta.json +4 -3
  13. package/extension/src/content/legacyGlobalRegistry.js +1 -0
  14. package/extension/src/content/modelPicker.js +21 -9
  15. package/extension/src/content/modelPickerSupport.js +38 -1
  16. package/extension/src/content/nativeCompatibilityController.js +4 -1
  17. package/extension/src/content/otWarmMirror.js +34 -30
  18. package/extension/src/content/otWarmMirrorController.js +20 -0
  19. package/extension/src/content/pageBridgeClient.js +2 -0
  20. package/extension/src/content/panelMaintenance.js +8 -4
  21. package/extension/src/content/recentProjects.js +354 -355
  22. package/extension/src/content/runResultActions.js +16 -1
  23. package/extension/src/content/runTimelineView.js +9 -11
  24. package/extension/src/content/scopedPersistenceCoordinator.js +2 -1
  25. package/extension/src/content/scopedPersistencePanelState.js +45 -2
  26. package/extension/src/content/sessionForkController.js +20 -11
  27. package/extension/src/content/sessionPersistence.js +4 -1
  28. package/extension/src/content/settingsPanel.js +16 -2
  29. package/extension/src/content/trackedChangeCaptureController.js +153 -0
  30. package/extension/src/content/writebackOrchestrator.js +31 -0
  31. package/extension/src/page/binaryAssetUploader.js +101 -29
  32. package/extension/src/page/overleafRealtimeObserver.js +4 -1
  33. package/extension/src/page/saveState.js +44 -1
  34. package/extension/src/page/textFileCreator.js +493 -0
  35. package/extension/src/page/trackedChangeCapture.js +342 -0
  36. package/extension/src/page/trackedChangesLifecycle.js +149 -150
  37. package/extension/src/page/treeOperations.js +40 -29
  38. package/extension/src/page/writebackRouter.js +95 -49
  39. package/extension/src/pageBridge.js +65 -25
  40. package/extension/src/shared/compatibility.js +1 -1
  41. package/extension/src/shared/i18n.js +94 -40
  42. package/extension/src/shared/pageRpcContract.js +2 -1
  43. package/extension/src/shared/sessionState.js +6 -2
  44. package/extension/src/shared/storageDb.js +11 -12
  45. package/extension/src/shared/storageRunActions.js +73 -1
  46. package/extension/src/shared/writebackSettlement.js +19 -5
  47. package/extension/styles/panel.css +254 -405
  48. package/native-host/src/codexSessionRunner.js +41 -38
  49. package/native-host/src/codexSessionTiming.js +68 -0
  50. package/native-host/src/readProgressGuard.js +84 -17
  51. package/native-host/src/taskRunnerRuntime.js +25 -0
  52. package/package.json +5 -1
@@ -34,6 +34,7 @@
34
34
  // (the row renderer is sync). The async loader populates this on panel
35
35
  // mount and on each opportunistic enrichment call.
36
36
  let projectNameCacheMirror = {};
37
+ let dashboardRenderGeneration = 0;
37
38
 
38
39
  function loadProjectNameCacheFromStorage() {
39
40
  return new Promise(function (resolve) {
@@ -140,7 +141,7 @@
140
141
  // If the variant is currently visible, refresh row names in place.
141
142
  var visibleList = getPanel() && getPanel().querySelector('[data-recent-projects-list]');
142
143
  if (visibleList) {
143
- var rows = visibleList.querySelectorAll('[data-recent-projects-row]');
144
+ var rows = visibleList.querySelectorAll('[data-project-id]');
144
145
  for (var k = 0; k < rows.length; k++) {
145
146
  var row = rows[k];
146
147
  var rowPid = row.getAttribute('data-project-id');
@@ -149,6 +150,7 @@
149
150
  var cached = lookupProjectName(rowPid);
150
151
  if (cached) {
151
152
  nameEl.textContent = cached;
153
+ nameEl.title = cached;
152
154
  }
153
155
  }
154
156
  }
@@ -229,64 +231,55 @@
229
231
  var hr = Math.round(min / 60);
230
232
  if (hr < 24) return tx(hr + ' hr ago', hr + ' 小时前');
231
233
  var day = Math.round(hr / 24);
232
- if (day < 30) return tx(day + ' day ago', day + ' 天前');
234
+ if (day < 30) return tx(day + (day === 1 ? ' day ago' : ' days ago'), day + ' 天前');
233
235
  var month = Math.round(day / 30);
234
236
  if (month < 12) return tx(month + ' mo ago', month + ' 个月前');
235
237
  var year = Math.round(month / 12);
236
238
  return tx(year + ' yr ago', year + ' 年前');
237
239
  }
238
240
 
239
- function textNode(text, className) {
240
- var el = document.createElement('span');
241
- if (className) {
242
- el.className = className;
243
- }
244
- el.textContent = text == null ? '' : String(text);
241
+ function createViewElement(tag, className, text) {
242
+ var el = document.createElement(tag);
243
+ if (className) el.className = className;
244
+ if (text !== undefined) el.textContent = text;
245
245
  return el;
246
246
  }
247
247
 
248
- function renderWelcomeHeader(stats) {
249
- var el = document.createElement('div');
250
- el.className = 'recent-projects-welcome';
248
+ function createViewButton(label, onClick, attribute, className) {
249
+ var button = createViewElement('button', className, label);
250
+ button.type = 'button';
251
+ if (attribute) button.setAttribute(attribute, '');
252
+ if (onClick) button.addEventListener('click', onClick);
253
+ return button;
254
+ }
255
+
256
+ function textNode(text, className) {
257
+ return createViewElement('span', className, text == null ? '' : String(text));
258
+ }
259
+
260
+ function renderWelcomeHeader() {
261
+ var el = createViewElement('div', 'recent-projects-welcome');
251
262
  el.setAttribute('data-recent-projects-welcome', '');
252
- var title = document.createElement('div');
253
- title.className = 'recent-projects-welcome-title';
254
- title.textContent = tr('recentProjects_welcome');
255
- var subtitle = document.createElement('div');
256
- subtitle.className = 'recent-projects-welcome-subtitle';
257
- // v1.8.1: the most expensive spot on the first screen now carries a
258
- // glanceable summary instead of boilerplate once the rows are known.
259
- subtitle.textContent = stats && stats.projectCount
260
- ? tr('recentProjects_welcome_stats', { projects: stats.projectCount, sessions: stats.sessionTotal })
261
- : tr('recentProjects_welcome_subtitle');
262
- el.appendChild(title);
263
- el.appendChild(subtitle);
263
+ el.appendChild(textNode('CODEX', 'recent-projects-eyebrow'));
264
+ el.appendChild(textNode(tr('recentProjects_welcome'), 'recent-projects-welcome-title'));
265
+ el.appendChild(textNode(tr('recentProjects_welcome_subtitle'), 'recent-projects-welcome-subtitle'));
264
266
  return el;
265
267
  }
266
268
 
267
269
  function renderEmptyState() {
268
- var el = document.createElement('div');
269
- el.className = 'recent-projects-empty';
270
+ var el = createViewElement('div', 'recent-projects-empty', tr('recentProjects_empty'));
270
271
  el.setAttribute('data-recent-projects-empty', '');
271
- el.textContent = tr('recentProjects_empty');
272
272
  return el;
273
273
  }
274
274
 
275
275
  function renderDegradedState() {
276
- var el = document.createElement('div');
277
- el.className = 'recent-projects-degraded';
276
+ var el = createViewElement('div', 'recent-projects-degraded');
278
277
  el.setAttribute('data-recent-projects-degraded', '');
279
278
  // v1.8.1: the user IS signed in on /project — telling them to sign in
280
279
  // was a dead end. Explain the real condition and offer a retry.
281
- var hint = document.createElement('div');
282
- hint.textContent = tr('recentProjects_degraded_hint');
280
+ var hint = createViewElement('div', '', tr('recentProjects_degraded_hint'));
283
281
  el.appendChild(hint);
284
- var retry = document.createElement('button');
285
- retry.type = 'button';
286
- retry.className = 'recent-projects-show-all';
287
- retry.setAttribute('data-recent-projects-retry', '');
288
- retry.textContent = tr('recentProjects_retryScope');
289
- retry.addEventListener('click', function () {
282
+ var retry = createViewButton(tr('recentProjects_retryScope'), function () {
290
283
  if (typeof refreshAccountScopeId === 'function') {
291
284
  Promise.resolve(refreshAccountScopeId())
292
285
  .then(function () { return renderRecentProjectsVariant(); })
@@ -294,7 +287,7 @@
294
287
  } else {
295
288
  renderRecentProjectsVariant();
296
289
  }
297
- });
290
+ }, 'data-recent-projects-retry', 'recent-projects-show-all');
298
291
  el.appendChild(retry);
299
292
  return el;
300
293
  }
@@ -304,18 +297,11 @@
304
297
  // skills tied to projects, custom instructions, project diagnostics).
305
298
  function renderSettingsEntry(options) {
306
299
  var scope = options && options.scope === 'account' ? 'account' : 'project';
307
- var entry = document.createElement('button');
308
- entry.type = 'button';
309
- entry.className = 'recent-projects-settings-entry';
310
- entry.setAttribute('data-recent-projects-settings-entry', '');
311
- entry.setAttribute('data-settings-scope', scope);
312
- var label = document.createElement('span');
313
- label.className = 'recent-projects-settings-entry-label';
314
- label.textContent = tr('recentProjects_settings_entry');
315
- entry.appendChild(label);
316
- entry.addEventListener('click', function () {
300
+ var entry = createViewButton(undefined, function () {
317
301
  openSettingsInScope(scope);
318
- });
302
+ }, 'data-recent-projects-settings-entry', 'recent-projects-settings-entry');
303
+ entry.setAttribute('data-settings-scope', scope);
304
+ entry.appendChild(textNode(tr('recentProjects_settings_entry'), 'recent-projects-settings-entry-label'));
319
305
  return entry;
320
306
  }
321
307
 
@@ -360,10 +346,8 @@
360
346
  ? status
361
347
  : 'pending';
362
348
  var cls = STATUS_BADGE_CLASS[safeStatus];
363
- var el = document.createElement('span');
364
- el.className = 'recent-projects-row-badge ' + cls;
349
+ var el = textNode(tr('recentProjects_badge_' + safeStatus), 'recent-projects-row-badge ' + cls);
365
350
  el.setAttribute('data-status', safeStatus);
366
- el.textContent = tr('recentProjects_badge_' + safeStatus);
367
351
  return el;
368
352
  }
369
353
 
@@ -378,125 +362,145 @@
378
362
  window.location.assign('https://www.overleaf.com/project/' + encodeURIComponent(projectId));
379
363
  }
380
364
 
381
- function renderRecentProjectRow(row) {
382
- var projectId = row && row.projectId;
383
- var valid = isValidProjectId(projectId);
384
- var el = document.createElement('button');
385
- el.type = 'button';
386
- el.className = 'recent-projects-row';
387
- el.setAttribute('data-recent-projects-row', '');
388
- el.setAttribute('data-project-id', projectId || '');
389
- if (!valid) {
390
- el.disabled = true;
391
- el.setAttribute('aria-disabled', 'true');
392
- }
393
- var name = lookupProjectName(projectId);
394
- if (!name) {
395
- name = isValidProjectId(projectId)
396
- ? tr('recentProjects_unnamed', { prefix: projectId.slice(0, 8) })
397
- : tr('recentProjects_row_projectLinkUnavailable');
398
- }
399
- var nameNode = textNode(name, 'recent-projects-row-name');
400
- nameNode.title = name;
401
- el.appendChild(nameNode);
402
- var rowTime = textNode(formatRelativeTime(row && row.lastActivityAt), 'recent-projects-row-time');
403
- rowTime.setAttribute('data-rel-time', String(row && row.lastActivityAt || ''));
404
- el.appendChild(rowTime);
405
- // v1.8.2: the session count lives in the row meta, matching its type
406
- // scale, instead of inflating the expand toggle.
407
- var sessionCount = Number(row && row.sessionCount) || 0;
408
- if (sessionCount > 1) {
409
- el.appendChild(textNode(tr('recentProjects_sessionsCount', { count: sessionCount }), 'recent-projects-row-sessions'));
410
- }
411
- el.appendChild(renderStatusBadge(settleDashboardRunStatus(row && row.primaryStatusBadge, row && row.lastActivityAt)));
412
- if (valid) {
413
- el.addEventListener('click', function () {
414
- openProjectFromRow(projectId);
415
- });
416
- } else {
417
- el.appendChild(textNode(tr('recentProjects_row_projectLinkUnavailable'), 'recent-projects-row-warning'));
418
- }
419
- if (!valid) {
420
- var deadWrap = document.createElement('div');
421
- deadWrap.className = 'recent-projects-row-wrap';
422
- var deadHead = document.createElement('div');
423
- deadHead.className = 'recent-projects-row-head';
424
- var cleanup = document.createElement('button');
425
- cleanup.type = 'button';
426
- cleanup.className = 'recent-projects-row-cleanup';
427
- cleanup.setAttribute('data-row-cleanup', '');
428
- cleanup.title = tr('recentProjects_cleanup');
429
- cleanup.setAttribute('aria-label', tr('recentProjects_cleanup'));
430
- cleanup.textContent = '×';
431
- cleanup.addEventListener('click', function () {
432
- cleanupDeadProjectEntry(projectId).catch(function () { /* swallow */ });
433
- });
434
- deadHead.appendChild(el);
435
- deadHead.appendChild(cleanup);
436
- deadWrap.appendChild(deadHead);
437
- return deadWrap;
438
- }
439
- var wrap = document.createElement('div');
440
- wrap.className = 'recent-projects-row-wrap';
441
- wrap.setAttribute('data-project-row-wrap', projectId);
442
- var expand = document.createElement('button');
443
- expand.type = 'button';
444
- expand.className = 'recent-projects-row-expand';
445
- expand.setAttribute('data-row-expand', '');
446
- expand.setAttribute('aria-expanded', 'false');
447
- // v1.8.1 a11y: tie the toggle to the container it reveals.
448
- var sessionsElId = 'codex-project-sessions-' + String(projectId || '').slice(0, 24);
449
- expand.setAttribute('aria-controls', sessionsElId);
450
- expand.title = tr('recentProjects_sessions_toggle');
451
- expand.setAttribute('aria-label', tr('recentProjects_sessions_toggle'));
452
- expand.textContent = tr('recentProjects_sessions_action');
453
- var head = document.createElement('div');
454
- head.className = 'recent-projects-row-head';
455
- var clear = document.createElement('button');
456
- clear.type = 'button';
457
- clear.className = 'recent-projects-row-clear';
458
- clear.setAttribute('data-project-clear', '');
459
- clear.title = tr('recentProjects_clearProject');
460
- clear.setAttribute('aria-label', tr('recentProjects_clearProject'));
461
- clear.textContent = '×';
462
- clear.addEventListener('click', function (event) {
463
- event.stopPropagation();
464
- projectSessionCleanup.clearProjectSessions(projectId, name).catch(function () { /* surfaced by the action */ });
365
+ function renderActionsMenu(buttons) {
366
+ var menu = createViewElement('details', 'recent-projects-menu');
367
+ var trigger = createViewElement('summary', '', '\u22ef');
368
+ trigger.title = tr('recentProjects_more');
369
+ trigger.setAttribute('aria-label', tr('recentProjects_more'));
370
+ var actions = document.createElement('div');
371
+ actions.setAttribute('data-recent-menu-actions', '');
372
+ buttons.forEach(function (button) { actions.appendChild(button); });
373
+ actions.addEventListener('click', function (event) {
374
+ if (event.target.closest('button')) {
375
+ menu.open = false;
376
+ trigger.focus();
377
+ }
378
+ }, true);
379
+ menu.appendChild(trigger);
380
+ menu.appendChild(actions);
381
+ menu.addEventListener('keydown', function (event) {
382
+ if (event.key === 'Escape') {
383
+ event.preventDefault();
384
+ event.stopPropagation();
385
+ menu.open = false;
386
+ trigger.focus();
387
+ }
465
388
  });
466
- head.appendChild(el);
467
- head.appendChild(expand);
468
- head.appendChild(clear);
469
- var sessionsEl = document.createElement('div');
470
- sessionsEl.className = 'recent-projects-sessions';
471
- sessionsEl.setAttribute('data-project-sessions', '');
472
- sessionsEl.id = sessionsElId;
473
- sessionsEl.setAttribute('role', 'region');
474
- sessionsEl.setAttribute('aria-label', tr('recentProjects_sessions_toggle'));
475
- sessionsEl.hidden = true;
476
- expand.addEventListener('click', function () {
477
- toggleProjectSessions(wrap, projectId);
389
+ menu.addEventListener('focusout', function (event) {
390
+ if (event.relatedTarget && !menu.contains(event.relatedTarget)) menu.open = false;
478
391
  });
479
- wrap.appendChild(head);
480
- wrap.appendChild(sessionsEl);
481
- return wrap;
392
+ return menu;
482
393
  }
483
394
 
484
- function toggleProjectSessions(wrap, projectId) {
485
- var sessionsEl = wrap.querySelector('[data-project-sessions]');
486
- var expand = wrap.querySelector('[data-row-expand]');
487
- if (!sessionsEl || !expand) {
488
- return;
395
+ function conversationActivity(record) {
396
+ return String(record && (record.lastActivityAt || record.updatedAt || record.createdAt) || '');
397
+ }
398
+
399
+ function conversationHasContent(record) {
400
+ return ['runs', 'history', 'pendingInputs'].some(function (key) {
401
+ return Array.isArray(record[key]) && record[key].length > 0;
402
+ }) || ['task', 'safeTaskSummary', 'codexThreadId'].some(function (key) {
403
+ return typeof record[key] === 'string' && Boolean(record[key].trim());
404
+ }) || (record.titleSource === 'manual' && typeof record.title === 'string' && Boolean(record.title.trim()));
405
+ }
406
+
407
+ function conversationStatus(record) {
408
+ if (!StorageDb || !Array.isArray(record.runs) || !record.runs.length) return '';
409
+ return settleDashboardRunStatus(StorageDb.derivePrimaryStatusBadge(record), conversationActivity(record));
410
+ }
411
+
412
+ function projectDisplayName(projectId) {
413
+ return lookupProjectName(projectId) || (isValidProjectId(projectId)
414
+ ? tr('recentProjects_unnamed', { prefix: projectId.slice(0, 8) })
415
+ : tr('recentProjects_row_projectLinkUnavailable'));
416
+ }
417
+
418
+ async function loadDashboardConversations(scope) {
419
+ if (!scope || !StorageDb) throw new Error('Conversation storage is unavailable.');
420
+ var results = await Promise.all([StorageDb.getAllSessions(), SessionPersistence.loadTombstones()]);
421
+ var records = results[0];
422
+ var deletedByProject = results[1] || {};
423
+ if (!Array.isArray(records)) throw new Error('Conversation history could not be read.');
424
+ return records.filter(function (record) {
425
+ if (!record || typeof record.id !== 'string' || !record.id || record.accountScopeUnavailable === true) return false;
426
+ var deletedIds = Object.prototype.hasOwnProperty.call(deletedByProject, record.projectId)
427
+ ? deletedByProject[record.projectId] : [];
428
+ return SessionPersistence.isVisibleRecord(record, Array.isArray(deletedIds) ? deletedIds : [], scope);
429
+ }).sort(function (a, b) {
430
+ var aTime = Date.parse(conversationActivity(a)) || 0;
431
+ var bTime = Date.parse(conversationActivity(b)) || 0;
432
+ return bTime - aTime || String(a.id).localeCompare(String(b.id));
433
+ });
434
+ }
435
+
436
+ function dashboardViewOptions() {
437
+ var root = getPanel() && getPanel().querySelector('[data-recent-projects-root]');
438
+ var drafts = root && root.querySelector('[data-unstarted-conversations]');
439
+ return {
440
+ accountScopeId: root && root.dataset.accountScopeId || '',
441
+ projectFilter: root && root.dataset.projectFilter || '',
442
+ showAll: Boolean(root && root.dataset.showAll === 'true'),
443
+ draftsOpen: Boolean(drafts && drafts.open),
444
+ restoreScrollTop: root ? root.scrollTop : 0
445
+ };
446
+ }
447
+
448
+ async function refreshDashboardView() {
449
+ var panelEl = getPanel();
450
+ if (panelEl && panelEl.dataset.view === 'recent-projects') {
451
+ await renderRecentProjectsVariant(dashboardViewOptions());
489
452
  }
490
- var opening = sessionsEl.hidden;
491
- sessionsEl.hidden = !opening;
492
- expand.setAttribute('aria-expanded', opening ? 'true' : 'false');
493
- expand.textContent = tr(opening ? 'recentProjects_sessions_action_open' : 'recentProjects_sessions_action');
494
- wrap.setAttribute('data-expanded', opening ? 'true' : 'false');
495
- if (opening) {
496
- renderProjectSessions(sessionsEl, projectId).catch(function () { /* swallow */ });
453
+ }
454
+
455
+ async function openDashboardConversation(projectId, record, row) {
456
+ var scope = record.accountScopeId;
457
+ var panelEl = getPanel();
458
+ if (!scope || scope !== getCachedAccountScopeId() || !isValidProjectId(projectId)
459
+ || !panelEl || panelEl.dataset.view !== 'recent-projects' || row.getAttribute('aria-busy') === 'true') return;
460
+ row.setAttribute('aria-busy', 'true');
461
+ var resume = row.querySelector('[data-session-resume]');
462
+ if (resume) resume.disabled = true;
463
+ try {
464
+ var records = await loadProjectSessionRecords(projectId);
465
+ if (scope !== getCachedAccountScopeId() || panelEl !== getPanel()
466
+ || panelEl.dataset.view !== 'recent-projects' || row.isConnected === false) return;
467
+ var selected = records.find(function (candidate) { return candidate.id === record.id; });
468
+ if (!selected) {
469
+ await refreshDashboardView();
470
+ showPluginToast(tr('recentProjects_conversationGone'), { status: 'warning' });
471
+ return;
472
+ }
473
+ await activateSessionAndOpenProject(projectId, selected);
474
+ } catch (_error) {
475
+ if (scope === getCachedAccountScopeId() && row.isConnected !== false) {
476
+ showPluginToast(tr('recentProjects_continueFailed'), { status: 'warning' });
477
+ }
478
+ } finally {
479
+ row.removeAttribute('aria-busy');
480
+ if (resume) resume.disabled = false;
497
481
  }
498
482
  }
499
483
 
484
+ // Kept as a compatibility entry point and as the recovery path for invalid project links.
485
+ function renderRecentProjectRow(row) {
486
+ var projectId = row && row.projectId;
487
+ var valid = isValidProjectId(projectId);
488
+ var wrap = createViewElement('div', 'recent-projects-row-wrap recent-projects-unavailable-row');
489
+ wrap.setAttribute('data-project-id', projectId || '');
490
+ var link = createViewButton(undefined, null, 'data-recent-projects-row', 'recent-projects-row');
491
+ link.setAttribute('data-project-id', projectId || '');
492
+ link.disabled = !valid;
493
+ link.appendChild(textNode(projectDisplayName(projectId), 'recent-projects-row-name'));
494
+ if (valid) link.addEventListener('click', function () { openProjectFromRow(projectId); });
495
+ var cleanup = createViewButton(tr(valid ? 'recentProjects_clearProject' : 'recentProjects_cleanup'), function () {
496
+ if (valid) projectSessionCleanup.clearProjectSessions(projectId, projectDisplayName(projectId)).catch(function () {});
497
+ else cleanupDeadProjectEntry(projectId).catch(function () {});
498
+ }, valid ? 'data-project-clear' : 'data-row-cleanup');
499
+ wrap.appendChild(link);
500
+ wrap.appendChild(renderActionsMenu([cleanup]));
501
+ return wrap;
502
+ }
503
+
500
504
  function sessionRecordDisplayTitle(record) {
501
505
  if (record && typeof record.title === 'string' && record.title.trim()) {
502
506
  return record.title.trim();
@@ -520,94 +524,86 @@
520
524
  });
521
525
  }
522
526
 
523
- async function renderProjectSessions(sessionsEl, projectId) {
524
- sessionsEl.innerHTML = '';
525
- var sessionsLoading = document.createElement('div');
526
- sessionsLoading.className = 'recent-projects-loading';
527
- sessionsLoading.textContent = tx('Loading sessions…', '正在加载会话…');
528
- sessionsEl.appendChild(sessionsLoading);
529
- var records = [];
530
- try {
531
- records = await loadProjectSessionRecords(projectId);
532
- } catch (_error) {
533
- records = [];
534
- }
535
- sessionsLoading.remove();
536
- if (!records.length) {
537
- sessionsEl.appendChild(textNode(tr('recentProjects_sessions_empty'), 'recent-projects-sessions-empty'));
538
- return;
539
- }
540
- for (var i = 0; i < records.length; i++) {
541
- sessionsEl.appendChild(renderProjectSessionRow(sessionsEl, projectId, records[i]));
542
- }
543
- }
544
-
545
- function renderProjectSessionRow(sessionsEl, projectId, record) {
527
+ function renderProjectSessionRow(sessionsEl, projectId, record, options) {
528
+ var featured = Boolean(options && options.featured);
546
529
  var running = Boolean(StorageDb)
547
530
  && settleDashboardRunStatus(StorageDb.derivePrimaryStatusBadge(record), record.updatedAt || record.lastActivityAt) === 'running';
548
- var row = document.createElement('div');
549
- row.className = 'recent-projects-session-row';
531
+ var row = createViewElement('div', 'recent-projects-session-row' + (featured ? ' recent-projects-focus' : ''));
550
532
  row.setAttribute('data-project-session-row', record.id);
551
- if (running) {
552
- row.setAttribute('data-running', 'true');
553
- }
533
+ row.setAttribute('data-project-id', projectId);
534
+ if (running) row.setAttribute('data-running', 'true');
554
535
  var title = textNode(sessionRecordDisplayTitle(record), 'recent-projects-session-title');
555
- title.title = sessionRecordDisplayTitle(record);
556
- var time = textNode(formatRelativeTime(record.updatedAt || record.createdAt), 'recent-projects-session-time');
557
- time.setAttribute('data-rel-time', String(record.updatedAt || record.createdAt || ''));
558
- var rename = document.createElement('button');
559
- rename.type = 'button';
560
- rename.className = 'recent-projects-session-action';
561
- rename.setAttribute('data-session-rename-dash', '');
562
- rename.title = tr('renameSession');
563
- rename.setAttribute('aria-label', tr('renameSession'));
564
- rename.textContent = '✎';
565
- var del = document.createElement('button');
566
- del.type = 'button';
567
- del.className = 'recent-projects-session-action recent-projects-session-action--delete';
568
- del.setAttribute('data-session-delete-dash', '');
569
- del.title = tr('deleteSession');
570
- del.setAttribute('aria-label', tr('deleteSession'));
571
- del.textContent = '×';
572
- if (running) {
573
- // Recently-touched running sessions may genuinely be live in another
574
- // tab: keep rename locked, but let delete through a stronger confirm
575
- // instead of a permanent dead-end (fleet: zombie sessions were
576
- // undeletable from the very surface built to manage them).
577
- rename.disabled = true;
578
- del.title = tr('recentProjects_zombieRunningNote');
536
+ title.title = title.textContent;
537
+ var copy = createViewElement('div', 'recent-projects-session-copy');
538
+ var meta = createViewElement('div', 'recent-projects-session-meta');
539
+ var project = textNode(projectDisplayName(projectId), 'recent-projects-row-name');
540
+ project.title = project.textContent;
541
+ meta.appendChild(project);
542
+ var time = textNode(formatRelativeTime(conversationActivity(record)), 'recent-projects-session-time');
543
+ time.setAttribute('data-rel-time', conversationActivity(record));
544
+ time.title = conversationActivity(record);
545
+ var status = conversationStatus(record);
546
+ var footer = featured ? createViewElement('div', 'recent-projects-focus-footer') : null;
547
+ if (featured) {
548
+ meta.insertBefore(textNode(tr('recentProjects_focusEyebrow'), 'recent-projects-focus-eyebrow'), project);
549
+ copy.appendChild(meta);
550
+ copy.appendChild(title);
551
+ footer.appendChild(status ? renderStatusBadge(status)
552
+ : textNode(tr('recentProjects_savedDraft'), 'recent-projects-row-badge'));
553
+ } else {
554
+ copy.appendChild(title);
555
+ copy.appendChild(meta);
556
+ if (['failed', 'needs_review', 'interrupted', 'stale', 'needs_review_after_navigation', 'abandoned_after_navigation'].indexOf(status) !== -1) {
557
+ var attention = textNode(tr('recentProjects_attention'), 'recent-projects-session-attention');
558
+ attention.title = tr('recentProjects_badge_' + status);
559
+ meta.appendChild(attention);
560
+ }
579
561
  }
580
- rename.addEventListener('click', function () {
562
+ meta.appendChild(time);
563
+ row.appendChild(copy);
564
+
565
+ var rename = createViewButton(tr('renameSession'), function () {
581
566
  beginDashboardSessionRename(sessionsEl, projectId, record, row, title);
582
- });
583
- del.addEventListener('click', function () {
584
- deleteDashboardSession(sessionsEl, projectId, record).catch(function () { /* swallow */ });
585
- });
586
- row.appendChild(title);
587
- row.appendChild(time);
588
- row.appendChild(rename);
589
- row.appendChild(del);
590
- // v1.8.1 O2: session rows were click-dead. Clicking one now records it
591
- // as the project's active session (the pref the editor reads on load)
592
- // and opens the project straight into it.
593
- row.classList.add('recent-projects-session-row--linked');
594
- row.setAttribute('role', 'button');
595
- row.tabIndex = 0;
596
- var activate = function () {
597
- activateSessionAndOpenProject(projectId, record).catch(function () { /* navigation is best-effort */ });
598
- };
599
- row.addEventListener('click', function (event) {
600
- if (event.target.closest('button') || event.target.closest('input')) {
601
- return;
602
- }
603
- activate();
604
- });
605
- row.addEventListener('keydown', function (event) {
606
- if (event.key === 'Enter') {
607
- event.preventDefault();
608
- activate();
609
- }
610
- });
567
+ }, 'data-session-rename-dash');
568
+ rename.title = rename.textContent;
569
+ rename.setAttribute('aria-label', rename.textContent);
570
+ if (running) rename.disabled = true;
571
+ var del = createViewButton(tr('deleteSession'), function () {
572
+ deleteDashboardSession(sessionsEl, projectId, record).catch(function () {});
573
+ }, 'data-session-delete-dash');
574
+ del.title = running ? tr('recentProjects_zombieRunningNote') : del.textContent;
575
+ del.setAttribute('aria-label', del.textContent);
576
+ var projectConversations = createViewButton(tr('recentProjects_projectConversations'), function () {
577
+ renderRecentProjectsVariant({ projectFilter: projectId, showAll: true, restoreScrollTop: 0 }).catch(function () {});
578
+ }, 'data-project-conversations');
579
+ var openProject = createViewButton(tr('recentProjects_openProject'), function () { openProjectFromRow(projectId); });
580
+ var clear = createViewButton(tr('recentProjects_clearProject'), function () {
581
+ projectSessionCleanup.clearProjectSessions(projectId, projectDisplayName(projectId)).catch(function () {});
582
+ }, 'data-project-clear');
583
+ row.appendChild(renderActionsMenu([rename, del, projectConversations, openProject, clear]));
584
+
585
+ var activate = function () { openDashboardConversation(projectId, record, row).catch(function () {}); };
586
+ if (featured) {
587
+ row.setAttribute('role', 'group');
588
+ row.setAttribute('aria-label', tr('recentProjects_focusEyebrow'));
589
+ var resume = createViewButton(tr('recentProjects_continue'), activate, 'data-session-resume', 'recent-projects-continue');
590
+ resume.title = tr('recentProjects_continueHint');
591
+ footer.appendChild(resume);
592
+ row.appendChild(footer);
593
+ } else {
594
+ row.classList.add('recent-projects-session-row--linked');
595
+ row.setAttribute('role', 'button');
596
+ row.tabIndex = 0;
597
+ row.addEventListener('click', function (event) {
598
+ if (!event.target.closest('button, input, details')) activate();
599
+ });
600
+ row.addEventListener('keydown', function (event) {
601
+ if (event.target === row && (event.key === 'Enter' || event.key === ' ')) {
602
+ event.preventDefault();
603
+ activate();
604
+ }
605
+ });
606
+ }
611
607
  return row;
612
608
  }
613
609
 
@@ -757,16 +753,7 @@
757
753
  } catch (error) {
758
754
  showPluginToast(tr('deleteSessionHistoryFailedToast', { message: error.message }), { status: 'warning', sticky: true });
759
755
  }
760
- // Re-render the whole variant so the row summary/badge reflect the
761
- // deletion, then restore this project's expanded session list.
762
- // v1.8.1: refresh the whole variant so the row's session count, badge,
763
- // summary and the welcome stats all stay fresh — but restore the scroll
764
- // position and this project's expanded list so the delete feels in-place.
765
- var rootForScroll = getPanel() && getPanel().querySelector('[data-recent-projects-root]');
766
- await renderRecentProjectsVariant({
767
- expandProjectId: projectId,
768
- restoreScrollTop: rootForScroll ? rootForScroll.scrollTop : undefined
769
- });
756
+ await refreshDashboardView();
770
757
  }
771
758
 
772
759
  function beginDashboardSessionRename(sessionsEl, projectId, record, row, titleEl) {
@@ -780,7 +767,7 @@
780
767
  input.className = 'recent-projects-session-rename-input';
781
768
  input.value = seed;
782
769
  titleEl.hidden = true;
783
- row.insertBefore(input, titleEl);
770
+ titleEl.parentElement.insertBefore(input, titleEl);
784
771
  input.focus();
785
772
  input.select();
786
773
  var settled = false;
@@ -839,7 +826,7 @@
839
826
  })));
840
827
  }
841
828
  } catch (_error) { /* swallow */ }
842
- await renderProjectSessions(sessionsEl, projectId);
829
+ await refreshDashboardView();
843
830
  }
844
831
 
845
832
  function ensureRecentProjectsRoot() {
@@ -850,9 +837,13 @@
850
837
  if (existing) {
851
838
  return existing;
852
839
  }
853
- var rootEl = document.createElement('section');
854
- rootEl.className = 'recent-projects-root';
840
+ var rootEl = createViewElement('section', 'recent-projects-root');
855
841
  rootEl.setAttribute('data-recent-projects-root', '');
842
+ rootEl.addEventListener('click', function (event) {
843
+ rootEl.querySelectorAll('.recent-projects-menu[open]').forEach(function (menu) {
844
+ if (!menu.contains(event.target)) menu.open = false;
845
+ });
846
+ });
856
847
  // Insert as a sibling of the existing per-project main / composer slots
857
848
  // so the variant lives inside the panel root (page-scoped) and the
858
849
  // existing data-view CSS rules can hide it when the per-project view is
@@ -901,133 +892,141 @@
901
892
  return;
902
893
  }
903
894
  rootEl.appendChild(renderWelcomeHeader());
904
- var loading = document.createElement('div');
905
- loading.className = 'recent-projects-loading';
906
- loading.textContent = tx('Loading projects\u2026', '正在加载项目…');
895
+ var loading = createViewElement('div', 'recent-projects-loading', tx('Loading projects\u2026', '正在加载项目…'));
907
896
  rootEl.appendChild(loading);
908
897
  }
909
898
 
910
899
  async function renderRecentProjectsVariant(options) {
911
- if (!getPanel()) {
912
- return;
913
- }
914
- // Toggle panel into recent-projects mode. Page-scoped: never replaces
915
- // top-level page DOM. The existing data-view-driven CSS hides per-
916
- // project regions when the panel root carries this value.
917
- getPanel().dataset.view = 'recent-projects';
900
+ var panelEl = getPanel();
901
+ var editorMatch = String(window.location && window.location.pathname || '').match(/^\/project\/([a-f0-9]{24})(?:\/|$)/);
902
+ if (!panelEl || (editorMatch && isValidProjectId(editorMatch[1]))) return;
903
+ var previous = dashboardViewOptions();
904
+ options = Object.assign({}, previous, options || {});
905
+ var generation = ++dashboardRenderGeneration;
906
+ panelEl.dataset.view = 'recent-projects';
918
907
  startRelativeTimeTicker();
919
908
  var rootEl = ensureRecentProjectsRoot();
920
- if (!rootEl) {
921
- return;
909
+ if (!rootEl) return;
910
+ var accountScopeId = (window.codexOverleafDeriveAccountScopeId || function () { return null; })();
911
+ if (previous.accountScopeId !== accountScopeId) {
912
+ options.projectFilter = '';
913
+ options.showAll = options.draftsOpen = false;
922
914
  }
915
+ var projectFilter = isValidProjectId(options.projectFilter) ? options.projectFilter : '';
916
+ rootEl.dataset.accountScopeId = accountScopeId || '';
917
+ rootEl.dataset.projectFilter = projectFilter;
918
+ rootEl.dataset.showAll = options.showAll ? 'true' : 'false';
923
919
  rootEl.innerHTML = '';
924
920
  rootEl.appendChild(renderWelcomeHeader());
925
-
926
- var accountScopeId = (window.codexOverleafDeriveAccountScopeId || function () { return null; })();
927
921
  if (!accountScopeId) {
928
922
  rootEl.appendChild(renderDegradedState());
929
923
  rootEl.appendChild(renderSettingsEntry({ scope: 'account' }));
930
- opportunisticEnrichmentFromDom().catch(function () { /* swallow */ });
931
924
  return;
932
925
  }
933
-
934
- var listContainer = document.createElement('div');
935
- listContainer.className = 'recent-projects-list';
926
+ if (projectFilter) {
927
+ var filterBar = createViewElement('div', 'recent-projects-filter');
928
+ var back = createViewButton(tr('recentProjects_allProjects'), function () {
929
+ renderRecentProjectsVariant({ projectFilter: '', showAll: false, restoreScrollTop: 0 }).catch(function () {});
930
+ });
931
+ filterBar.appendChild(back);
932
+ var filterName = textNode(projectDisplayName(projectFilter));
933
+ filterName.title = filterName.textContent;
934
+ filterBar.appendChild(filterName);
935
+ rootEl.appendChild(filterBar);
936
+ }
937
+ var listContainer = createViewElement('div', 'recent-projects-list');
936
938
  listContainer.setAttribute('data-recent-projects-list', '');
937
939
  rootEl.appendChild(listContainer);
938
-
939
- // Slow IndexedDB reads must not render as "no projects": show a loading
940
- // line for the await window below (cleared before rows/empty render).
941
- var listLoading = document.createElement('div');
942
- listLoading.className = 'recent-projects-loading';
943
- listLoading.textContent = tx('Loading projects…', '正在加载项目…');
944
- listContainer.appendChild(listLoading);
945
-
946
- // Pre-warm the project-name cache mirror so the synchronous
947
- // `lookupProjectName` calls inside `renderRecentProjectRow` see the
948
- // latest data the first time the variant renders.
940
+ var loading = textNode(tr('recentProjects_loadingHistory'), 'recent-projects-loading');
941
+ listContainer.appendChild(loading);
942
+ function stillCurrent() {
943
+ return generation === dashboardRenderGeneration && getPanel() === panelEl
944
+ && panelEl.dataset.view === 'recent-projects' && rootEl.isConnected !== false
945
+ && getCachedAccountScopeId() === accountScopeId;
946
+ }
947
+ var records;
949
948
  try {
950
949
  await loadProjectNameCacheFromStorage();
951
- } catch (_error) { /* swallow; fall back to empty mirror */ }
952
-
953
- var rows = [];
954
- var showAll = Boolean(options && options.showAll);
955
- // Fetch one extra row beyond the fold so "show all" only renders when
956
- // there really is more than one page of projects.
957
- var pageLimit = 10;
958
- try {
959
- if (StorageDb) {
960
- var deletedSessionIdsByProject = await SessionPersistence.loadTombstones();
961
- rows = await StorageDb.listRecentProjectsAcrossAccount({
962
- accountScopeId: accountScopeId,
963
- limit: showAll ? 500 : pageLimit + 1,
964
- deletedSessionIdsByProject: deletedSessionIdsByProject
965
- });
966
- }
950
+ records = await loadDashboardConversations(accountScopeId);
967
951
  } catch (_error) {
968
- rows = [];
952
+ if (!stillCurrent()) return;
953
+ loading.textContent = tr('recentProjects_loadFailed');
954
+ var retry = createViewButton(tr('recentProjects_retryScope'), function () {
955
+ renderRecentProjectsVariant(options).catch(function () {});
956
+ }, null, 'recent-projects-show-all');
957
+ listContainer.appendChild(retry);
958
+ rootEl.appendChild(renderSettingsEntry({ scope: 'account' }));
959
+ return;
969
960
  }
970
-
971
- listLoading.remove();
972
- // v1.8.1 P2: a refresh must not clobber what the user had open — restore
973
- // every previously-expanded project and the scroll position.
974
- var restoreExpanded = Array.isArray(options && options.restoreExpanded) ? options.restoreExpanded : [];
975
- // v1.8.1 O1: fill the welcome subtitle with a real summary now that the
976
- // rows are known (header was painted before the async read).
977
- var welcomeSubtitle = rootEl.querySelector('.recent-projects-welcome-subtitle');
978
- if (welcomeSubtitle && rows && rows.length) {
979
- var sessionTotal = 0;
980
- for (var st = 0; st < rows.length; st++) {
981
- sessionTotal += Number(rows[st].sessionCount) || 1;
961
+ if (!stillCurrent()) return;
962
+ loading.remove();
963
+ var validRecords = records.filter(function (record) {
964
+ return isValidProjectId(record.projectId) && (!projectFilter || record.projectId === projectFilter);
965
+ });
966
+ var started = validRecords.filter(conversationHasContent);
967
+ var unstarted = validRecords.filter(function (record) { return !conversationHasContent(record); });
968
+ if (started.length) {
969
+ listContainer.appendChild(renderProjectSessionRow(listContainer, started[0].projectId, started[0], { featured: true }));
970
+ if (started.length > 1) {
971
+ var heading = createViewElement('div', 'recent-projects-history-heading');
972
+ heading.appendChild(textNode(tr('recentProjects_historyHeading')));
973
+ heading.appendChild(textNode(tr('recentProjects_historyHint')));
974
+ listContainer.appendChild(heading);
975
+ var historyList = createViewElement('div', 'recent-projects-history-list');
976
+ var history = options.showAll ? started.slice(1) : started.slice(1, 10);
977
+ history.forEach(function (record) {
978
+ historyList.appendChild(renderProjectSessionRow(historyList, record.projectId, record));
979
+ });
980
+ listContainer.appendChild(historyList);
981
+ if (!options.showAll && started.length > 10) {
982
+ var more = createViewButton(tr('recentProjects_showAllConversations', { count: started.length }), function () {
983
+ renderRecentProjectsVariant({ showAll: true }).catch(function () {});
984
+ }, 'data-recent-projects-show-all', 'recent-projects-show-all');
985
+ listContainer.appendChild(more);
986
+ }
982
987
  }
983
- welcomeSubtitle.textContent = tr('recentProjects_welcome_stats', {
984
- projects: rows.length,
985
- sessions: sessionTotal
986
- });
987
- }
988
- var hasMore = !showAll && rows && rows.length > pageLimit;
989
- var visibleRows = hasMore ? rows.slice(0, pageLimit) : rows;
990
- if (!visibleRows || !visibleRows.length) {
991
- listContainer.appendChild(renderEmptyState());
992
988
  } else {
993
- for (var i = 0; i < visibleRows.length; i++) {
994
- listContainer.appendChild(renderRecentProjectRow(visibleRows[i]));
989
+ var empty = renderEmptyState();
990
+ if (unstarted.length) empty.textContent = tr('recentProjects_noStarted');
991
+ listContainer.appendChild(empty);
992
+ }
993
+ if (unstarted.length) {
994
+ var drafts = createViewElement('details', 'recent-projects-drafts');
995
+ drafts.setAttribute('data-unstarted-conversations', '');
996
+ var summary = createViewElement('summary', '', tr('recentProjects_unstarted', { count: unstarted.length }));
997
+ drafts.appendChild(summary);
998
+ var draftsBuilt = false;
999
+ function populateDrafts() {
1000
+ if (draftsBuilt || !stillCurrent()) return;
1001
+ draftsBuilt = true;
1002
+ drafts.appendChild(textNode(tr('recentProjects_unstartedHint'), 'recent-projects-drafts-hint'));
1003
+ unstarted.forEach(function (record) {
1004
+ drafts.appendChild(renderProjectSessionRow(drafts, record.projectId, record));
1005
+ });
995
1006
  }
1007
+ drafts.addEventListener('toggle', function () { if (drafts.open) populateDrafts(); });
1008
+ listContainer.appendChild(drafts);
1009
+ if (options.draftsOpen) { drafts.open = true; populateDrafts(); }
996
1010
  }
997
- if (hasMore) {
998
- var showAllButton = document.createElement('button');
999
- showAllButton.type = 'button';
1000
- showAllButton.className = 'recent-projects-show-all';
1001
- showAllButton.setAttribute('data-recent-projects-show-all', '');
1002
- showAllButton.textContent = tx('Show all projects', '查看全部项目');
1003
- showAllButton.addEventListener('click', function () {
1004
- renderRecentProjectsVariant(Object.assign({}, options, { showAll: true }));
1011
+ if (!projectFilter) {
1012
+ var invalid = records.filter(function (record) { return !isValidProjectId(record.projectId); });
1013
+ var invalidIds = new Set();
1014
+ if (invalid.length) listContainer.appendChild(textNode(tr('recentProjects_unavailableRecords'), 'recent-projects-history-heading'));
1015
+ invalid.forEach(function (record) {
1016
+ var id = String(record.projectId || '');
1017
+ if (!invalidIds.has(id)) {
1018
+ invalidIds.add(id);
1019
+ listContainer.appendChild(renderRecentProjectRow({ projectId: record.projectId }));
1020
+ }
1005
1021
  });
1006
- listContainer.appendChild(showAllButton);
1007
1022
  }
1008
1023
  rootEl.appendChild(renderSettingsEntry({ scope: 'account' }));
1009
- var expandProjectId = options && options.expandProjectId;
1010
- var toReopen = restoreExpanded.slice();
1011
- if (expandProjectId && toReopen.indexOf(expandProjectId) === -1) {
1012
- toReopen.push(expandProjectId);
1013
- }
1014
- for (var reopenIndex = 0; reopenIndex < toReopen.length; reopenIndex++) {
1015
- var wrap = listContainer.querySelector('[data-project-row-wrap="' + toReopen[reopenIndex] + '"]');
1016
- if (wrap) {
1017
- toggleProjectSessions(wrap, toReopen[reopenIndex]);
1018
- }
1019
- }
1020
- if (options && Number.isFinite(options.restoreScrollTop)) {
1021
- rootEl.scrollTop = options.restoreScrollTop;
1022
- var scrollerParent = rootEl.parentElement;
1023
- if (scrollerParent && scrollerParent.scrollHeight > scrollerParent.clientHeight) {
1024
- scrollerParent.scrollTop = options.restoreScrollTop;
1025
- }
1026
- }
1027
- opportunisticEnrichmentFromDom().catch(function () { /* swallow */ });
1024
+ if (Number.isFinite(options.restoreScrollTop)) rootEl.scrollTop = options.restoreScrollTop;
1025
+ opportunisticEnrichmentFromDom().catch(function () {});
1028
1026
  }
1029
1027
 
1030
1028
  function renderPerProjectVariant() {
1029
+ dashboardRenderGeneration++;
1031
1030
  // Per-project mount: ensure the panel view attribute is back on the
1032
1031
  // session view and the variant root is detached so the per-project DOM
1033
1032
  // is the only thing visible. The existing applyStateToPanel path renders