codex-overleaf-link 2.5.1 → 2.6.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 (93) hide show
  1. package/README.md +149 -398
  2. package/README.zh-CN.md +153 -404
  3. package/extension/bootstrap/manifest.template.json +1 -1
  4. package/extension/src/background.js +78 -3
  5. package/extension/src/backgroundSharedSessions.js +55 -2
  6. package/extension/src/backgroundUpdateCoordinator.js +128 -12
  7. package/extension/src/content/activeTurnControl.js +164 -7
  8. package/extension/src/content/composerAttachments.js +11 -2
  9. package/extension/src/content/composerPanel.js +92 -12
  10. package/extension/src/content/contentRuntime.js +920 -276
  11. package/extension/src/content/contextTray.js +8 -2
  12. package/extension/src/content/contextUsageView.js +267 -0
  13. package/extension/src/content/diagnosticsPanel.js +18 -0
  14. package/extension/src/content/generated/content.bundle.js +4496 -924
  15. package/extension/src/content/generated/content.bundle.meta.json +6 -3
  16. package/extension/src/content/legacyGlobalRegistry.js +4 -0
  17. package/extension/src/content/localSkillsPanel.js +2 -2
  18. package/extension/src/content/markdownDomRenderer.js +5 -3
  19. package/extension/src/content/markdownText.js +42 -5
  20. package/extension/src/content/modelPicker.js +1 -0
  21. package/extension/src/content/nativeCompatibilityController.js +29 -10
  22. package/extension/src/content/otWarmMirror.js +1 -1
  23. package/extension/src/content/pageBridgeClient.js +2 -2
  24. package/extension/src/content/panelMaintenance.js +6 -2
  25. package/extension/src/content/panelRenderer.js +51 -27
  26. package/extension/src/content/pendingInputView.js +1 -1
  27. package/extension/src/content/projectSettingsCoordinator.js +30 -6
  28. package/extension/src/content/providerSettingsDialog.js +2 -1
  29. package/extension/src/content/recentProjects.js +4 -3
  30. package/extension/src/content/runActivitySummary.js +122 -14
  31. package/extension/src/content/runCompletionMeta.js +194 -6
  32. package/extension/src/content/runController.js +2 -1
  33. package/extension/src/content/runFailureNotice.js +8 -1
  34. package/extension/src/content/runPresence.js +18 -8
  35. package/extension/src/content/runQueueScheduler.js +9 -5
  36. package/extension/src/content/runRecoveryActions.js +2 -1
  37. package/extension/src/content/runResultActions.js +22 -20
  38. package/extension/src/content/runReviewController.js +94 -35
  39. package/extension/src/content/runTimelineView.js +224 -25
  40. package/extension/src/content/scopedPersistencePanelState.js +92 -23
  41. package/extension/src/content/sensitivePreflight.js +14 -1
  42. package/extension/src/content/sessionManager.js +49 -3
  43. package/extension/src/content/sessionMenuView.js +54 -18
  44. package/extension/src/content/sessionPersistence.js +25 -5
  45. package/extension/src/content/settingsPanel.js +15 -2
  46. package/extension/src/content/settingsWorkbench.js +127 -89
  47. package/extension/src/content/sharedSessionViewSync.js +4 -3
  48. package/extension/src/content/subagentActivityView.js +23 -7
  49. package/extension/src/content/updateNotice.js +60 -6
  50. package/extension/src/content/writebackOrchestrator.js +35 -22
  51. package/extension/src/page/compileBridge.js +193 -180
  52. package/extension/src/page/overleafCapabilities.js +4 -1
  53. package/extension/src/page/textCoordinates.js +17 -1
  54. package/extension/src/page/textFileCreator.js +9 -1
  55. package/extension/src/page/writebackRouter.js +24 -77
  56. package/extension/src/pageBridge.js +107 -66
  57. package/extension/src/popup.js +1 -1
  58. package/extension/src/shared/agentTranscript.js +350 -17
  59. package/extension/src/shared/auditRecords.js +2 -0
  60. package/extension/src/shared/compatibility.js +1 -1
  61. package/extension/src/shared/compileAdapter.js +58 -17
  62. package/extension/src/shared/composerAttachmentDraft.js +6 -1
  63. package/extension/src/shared/contextUsage.js +125 -0
  64. package/extension/src/shared/failureReasons.js +42 -3
  65. package/extension/src/shared/i18n.js +299 -29
  66. package/extension/src/shared/lineReferences.js +38 -31
  67. package/extension/src/shared/pageRpcContract.js +1 -1
  68. package/extension/src/shared/panelIcons.js +39 -0
  69. package/extension/src/shared/reviewing.js +130 -1
  70. package/extension/src/shared/runActivityModel.js +70 -4
  71. package/extension/src/shared/runExecutionSnapshot.js +13 -0
  72. package/extension/src/shared/sessionState.js +63 -4
  73. package/extension/src/shared/sessionTitle.js +14 -1
  74. package/extension/src/shared/sharedSessionBridge.js +81 -13
  75. package/extension/src/shared/staleGuard.js +72 -63
  76. package/extension/src/shared/storageDb.js +35 -3
  77. package/extension/src/shared/storageMigration.js +7 -1
  78. package/extension/src/shared/storageRunActions.js +62 -8
  79. package/extension/src/shared/undoOperations.js +126 -28
  80. package/extension/src/shared/updateRuntimeIdentity.js +16 -2
  81. package/extension/src/shared/writebackSettlement.js +21 -3
  82. package/extension/styles/panel.css +1106 -490
  83. package/native-host/src/bootstrapUpdate.js +52 -16
  84. package/native-host/src/chatBridgeRequest.js +60 -6
  85. package/native-host/src/chatBridgeResponse.js +10 -1
  86. package/native-host/src/chatCompletionsBridge.js +29 -2
  87. package/native-host/src/codexPromptAssembly.js +86 -118
  88. package/native-host/src/codexSessionRunner.js +126 -17
  89. package/native-host/src/providerContextError.js +20 -0
  90. package/native-host/src/taskRunnerRuntime.js +18 -6
  91. package/native-host/src/textPatch.js +473 -377
  92. package/native-host/src/updateManager.js +120 -15
  93. package/package.json +5 -8
@@ -33,6 +33,7 @@
33
33
  const RUN_PROJECT_SYNC_MAX_AGE_MS = 30000;
34
34
  const RUN_SNAPSHOT_ZIP_TIMEOUT_MS = 30000;
35
35
  const STREAM_RENDER_FLUSH_MS = 80;
36
+ const REVIEWING_CONFIRM_TIMEOUT_MS = 120000;
36
37
  const STREAM_SAVE_DELAY_MS = 1000;
37
38
  const MAX_RUN_EVENTS = 300;
38
39
  const MAX_SAFE_UNDO_REPLACEALL_CHARS = 2000;
@@ -98,7 +99,8 @@
98
99
  recordSessionResult,
99
100
  selectVisibleSessionsForList,
100
101
  setActiveSession,
101
- updateActiveSession
102
+ updateActiveSession,
103
+ updateActiveSessionDraft
102
104
  } = Modules.SessionState;
103
105
  const LineReferences = Modules.LineReferences;
104
106
  const { getProjectStorageKey } = Modules.StorageKeys;
@@ -403,7 +405,10 @@
403
405
  },
404
406
  getPanel: () => panel,
405
407
  getState: () => state,
406
- setState: next => { state = next; },
408
+ setState: next => {
409
+ if (next.activeSessionId !== state?.activeSessionId) composerDraftEditedHere = false;
410
+ state = next;
411
+ },
407
412
  getCurrentRunView: () => currentRunView,
408
413
  getSettingsPanelInstance: () => settingsPanelInstance,
409
414
  prepareRetryReplacement: (run, task) => prepareRetryReplacement(run, task)
@@ -424,7 +429,10 @@
424
429
  const sessionForkController = Modules.SessionForkController.create({
425
430
  SessionState: Modules.SessionState,
426
431
  getState: () => state,
427
- setState: next => { state = next; },
432
+ setState: next => {
433
+ if (next.activeSessionId !== state?.activeSessionId) composerDraftEditedHere = false;
434
+ state = next;
435
+ },
428
436
  getScope: () => ({ accountScopeId: cachedAccountScopeId, projectId: getCurrentProjectId(), generation: spaRouteGeneration }),
429
437
  sendBackgroundNative, saveState,
430
438
  getEnabledCodexLocalSkillIds: () => getEnabledCodexLocalSkillIds(),
@@ -440,15 +448,14 @@
440
448
  });
441
449
  const runResultActions = Modules.RunResultActions.create({
442
450
  tr,
443
- getLocale,
444
451
  forkRunFromNode: sessionForkController.forkRunFromNode
445
452
  });
446
453
  const runTimelineView = Modules.RunTimelineView.create({
447
454
  RunActivitySummary: Modules.RunActivitySummary,
448
455
  RunPresence: Modules.RunPresence,
456
+ PanelIcons: Modules.PanelIcons,
449
457
  // recentProjects is composed below; the empty state only reads it after startup.
450
458
  getProjectName: () => { try { return recentProjects.readCurrentProjectNameFromDom(); } catch { return ''; } },
451
- getCurrentProjectId: () => getCurrentProjectId(),
452
459
  RunActivityModel: Modules.RunActivityModel,
453
460
  SubagentActivityView: Modules.SubagentActivityView,
454
461
  RunGuidanceView: Modules.RunGuidanceView,
@@ -458,6 +465,7 @@
458
465
  RunFailureNotice: Modules.RunFailureNotice,
459
466
  I18n: i18n,
460
467
  FailureReasons,
468
+ projectContextLimitEvent: event => Modules.AgentTranscript.projectContextLimitEvent(event, getLocale()),
461
469
  localizeVisibleReason: value => localizeVisibleReason(value),
462
470
  localizeCompileReason: value => Modules.CompileAdapter.localizeCompileReason(value, getLocale()),
463
471
  tr,
@@ -477,7 +485,8 @@
477
485
  cssEscape,
478
486
  renderMarkdownInlineText,
479
487
  renderMarkdownBlockText,
480
- decorateCompletionReport: Modules.MarkdownDomRenderer.decorateCompletionReport,
488
+ decorateCompletionReport: (container, options) => Modules.MarkdownDomRenderer.decorateCompletionReport(container,
489
+ { ...options, formatOperationType }),
481
490
  sanitizeAssistantVisibleText,
482
491
  sanitizeAssistantVisibleValue,
483
492
  buildMarkdownInlineNodes,
@@ -521,6 +530,7 @@
521
530
  SessionPanel: Modules.SessionPanel,
522
531
  SessionState: Modules.SessionState,
523
532
  SessionMenuView: Modules.SessionMenuView,
533
+ PanelIcons: Modules.PanelIcons,
524
534
  tr,
525
535
  showPluginConfirm,
526
536
  showPluginToast,
@@ -528,10 +538,29 @@
528
538
  saveState,
529
539
  saveStateSoon,
530
540
  readPanelInputs,
541
+ flushComposerDraft: () => flushComposerDraft({ requireDurable: true }),
542
+ setSessionTransition: pending => {
543
+ if (pending) {
544
+ closeSlashMenu();
545
+ contextUsageView?.close();
546
+ }
547
+ const ready = composerPanelInstance?.setSessionTransition(pending);
548
+ syncComposerSendAvailability();
549
+ if (!pending && deferredSessionStart) {
550
+ const deferred = deferredSessionStart;
551
+ deferredSessionStart = null;
552
+ if (deferred.scope === getComposerDraftScope()) runQueueScheduler?.scheduleOne(deferred.sessionId);
553
+ }
554
+ return ready;
555
+ },
556
+ getDraftScope: getComposerDraftScope,
531
557
  applyStateToPanel, syncSessionProvider: () => providerSettingsCoordinator.syncSessionProvider(),
532
558
  getPanel: () => panel,
533
559
  getState: () => state,
534
- setState: next => { state = next; },
560
+ setState: next => {
561
+ if (next.activeSessionId !== state?.activeSessionId) composerDraftEditedHere = false;
562
+ state = next;
563
+ },
535
564
  getSessionPanelInstance: () => sessionPanelInstance,
536
565
  formatSessionTime
537
566
  });
@@ -595,6 +624,7 @@
595
624
  selectionContext: value,
596
625
  ...(options.edit ? { mode: 'auto' } : {})
597
626
  });
627
+ syncProbeStatus();
598
628
  if (options.edit && panel) {
599
629
  const modeInput = panel.querySelector('[data-mode]');
600
630
  if (modeInput) modeInput.value = 'auto';
@@ -762,6 +792,7 @@
762
792
  || { providerId: state?.providerId || 'builtin', providerRevision: 0 },
763
793
  readSelectedSpeedInput,
764
794
  getRenderedModelEntries,
795
+ onSelectionRendered: renderContextUsage,
765
796
  persistPanelInputs,
766
797
  closeDiagnosticsMenu,
767
798
  closeCustomInstructionsSettings,
@@ -844,8 +875,11 @@
844
875
  document.addEventListener('visibilitychange', () => {
845
876
  if (document.visibilityState === 'visible') {
846
877
  refreshDashboardSoon();
878
+ } else {
879
+ flushComposerDraftOnLeave();
847
880
  }
848
881
  });
882
+ window.addEventListener('pagehide', flushComposerDraftOnLeave);
849
883
 
850
884
  const recentProjects = Modules.RecentProjects.create({
851
885
  ProjectSessionCleanup: Modules.ProjectSessionCleanup,
@@ -887,6 +921,7 @@
887
921
  const MAX_ATTACHMENT_PREVIEW_DATA_URL_CHARS = 768 * 1024;
888
922
  const composerAttachmentController = Modules.ComposerAttachments.createComposerAttachmentController({
889
923
  getPanel: () => panel,
924
+ icons: Modules.PanelIcons,
890
925
  tr,
891
926
  tx,
892
927
  appendPlainLog,
@@ -917,11 +952,14 @@
917
952
  });
918
953
  const contextTrayController = ContextTray.createContextTrayController({
919
954
  root: window,
955
+ icons: Modules.PanelIcons,
920
956
  document,
921
957
  getPanel: () => panel,
922
958
  getState: () => state,
923
959
  setState: nextState => {
924
960
  state = nextState;
961
+ // Paint focus changes before ContextTray waits for durable persistence.
962
+ syncProbeStatus();
925
963
  },
926
964
  saveState,
927
965
  saveStateSoon,
@@ -985,6 +1023,7 @@
985
1023
  let settingsPanelInstance = null;
986
1024
  let diagnosticsPanelInstance = null;
987
1025
  let composerPanelInstance = null;
1026
+ let contextUsageView = null;
988
1027
  let state = null;
989
1028
  let storageKey = LEGACY_STORAGE_KEY;
990
1029
  let globalPreferencesController = null;
@@ -992,9 +1031,14 @@
992
1031
  let runSubmissionPending = false;
993
1032
  let pendingRetryReplacement = null;
994
1033
  let runQueueScheduler = null;
1034
+ let deferredSessionStart = null;
995
1035
  let runInputCoordinator = null;
996
1036
  const activeTurnControl = ActiveTurnControl.create({ chrome });
997
1037
  let saveStateTimer = null;
1038
+ const DRAFT_SAVE_DELAY_MS = 800;
1039
+ const DRAFT_SAVE_MAX_WAIT_MS = 4000;
1040
+ let draftSaveTimer = null;
1041
+ let pendingDraftSave = null;
998
1042
  // Two-phase saveState scheduling. `saveStateInFlight` flips to true while
999
1043
  // an async saveState() is actually writing; `saveStateRunAfterFlight` is
1000
1044
  // set when a fresh saveStateSoon() fires during the in-flight phase. When
@@ -1011,6 +1055,8 @@
1011
1055
  let storageNoticeKeys = new Set();
1012
1056
  let composerSkillInvocation = null;
1013
1057
  let composerDraftRevision = 0;
1058
+ // Page-local provenance only; the draft itself stays shared per conversation.
1059
+ let composerDraftEditedHere = false;
1014
1060
  let slashCodexOverleafSkills = [];
1015
1061
  let slashCodexOverleafSkillsLoaded = false;
1016
1062
  let slashCodexOverleafSkillsLoading = false;
@@ -1033,6 +1079,7 @@
1033
1079
  showPluginToast,
1034
1080
  getPanel: () => panel,
1035
1081
  ensurePanelOpen,
1082
+ getIconUrl: () => chrome.runtime.getURL('assets/icons/codex-overleaf-dialog-icon.png'),
1036
1083
  getPanelRendererInstance: () => panelRendererInstance,
1037
1084
  setBadge: (...args) => PanelRenderer.setBadge(...args),
1038
1085
  setDiagnosticsHealth,
@@ -1051,7 +1098,7 @@
1051
1098
  getBusyState: () => ({
1052
1099
  run: Boolean(currentRunView),
1053
1100
  cancelling: runCancellationRequested,
1054
- storage: saveStateInFlight || Boolean(saveStateTimer),
1101
+ storage: saveStateInFlight || Boolean(saveStateTimer) || Boolean(pendingDraftSave),
1055
1102
  reviewAction: trackedChangeInFlight.size > 0,
1056
1103
  dialog: Boolean(pluginConfirmController?.isOpen())
1057
1104
  }),
@@ -1081,7 +1128,7 @@
1081
1128
  chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
1082
1129
  if (message?.type === 'codex-overleaf/runtime-health-probe') {
1083
1130
  sendResponse?.({
1084
- ok: true,
1131
+ ok: true, runOwnerProtocol: 2, runOwners: [activeTurnControl.getOwner()].filter(Boolean),
1085
1132
  version: CodexOverleafCompatibility?.BUILD_TARGET_VERSION || chrome.runtime.getManifest().version
1086
1133
  });
1087
1134
  return false;
@@ -1146,6 +1193,7 @@
1146
1193
  await refreshAccountScopeId();
1147
1194
  storageKey = getProjectStorageKey(LEGACY_STORAGE_KEY, window.location.href);
1148
1195
  state = normalizeLoadedPanelState(await loadStoredState());
1196
+ composerDraftEditedHere = false;
1149
1197
  await getRunReviewController().recoverInterruptedUndoStatuses();
1150
1198
  initializeRunQueueScheduler();
1151
1199
  recoverInterruptedRunJournals()
@@ -1251,7 +1299,6 @@
1251
1299
  callbacks: {
1252
1300
  onLauncherToggle: () => ensurePanelOpen(),
1253
1301
  onClosePanel: () => closePanel(),
1254
- onRefresh: () => refreshProbe({ userInitiated: true }),
1255
1302
  onNewSession: () => startNewSession(),
1256
1303
  onChangeHistory: () => openChangeHistory(),
1257
1304
  onSettingsClick: () => toggleCustomInstructionsSettings(),
@@ -1273,7 +1320,9 @@
1273
1320
  diagnosticsPanelInstance = DiagnosticsPanel.create({
1274
1321
  container: panelRendererInstance.diagnosticsSlot,
1275
1322
  i18n: { tr },
1323
+ icons: Modules.PanelIcons,
1276
1324
  callbacks: {
1325
+ onRefresh: () => refreshProbe({ userInitiated: true }),
1277
1326
  onBeforeOpen: () => {
1278
1327
  closeModelConfigPopover();
1279
1328
  closeContextTray();
@@ -1311,6 +1360,7 @@
1311
1360
  projectId: getCurrentProjectId(),
1312
1361
  i18n: { tr },
1313
1362
  button: panel.querySelector('[data-custom-instructions-settings]'),
1363
+ getProjectName: () => { try { return recentProjects.readCurrentProjectNameFromDom(); } catch { return ''; } },
1314
1364
  callbacks: {
1315
1365
  onBack: () => closeCustomInstructionsSettings(),
1316
1366
  onInputChange: event => persistPanelInputs(event),
@@ -1351,9 +1401,12 @@
1351
1401
  composerPanelInstance = ComposerPanel.create({
1352
1402
  container: panelRendererInstance.composerSlot,
1353
1403
  i18n: { tr },
1404
+ icons: Modules.PanelIcons,
1354
1405
  attachmentController: composerAttachmentController,
1355
1406
  callbacks: {
1356
1407
  onSubmit: () => safeRunTask(),
1408
+ getTaskScope: getComposerDraftScope,
1409
+ getTaskValue: () => state.task,
1357
1410
  isRunning: () => Boolean(currentRunView),
1358
1411
  onCancel: () => cancelActiveRun(),
1359
1412
  onPaste: handleComposerPaste,
@@ -1369,7 +1422,7 @@
1369
1422
  onAttachClick: () => panel.querySelector('[data-attach-input]')?.click(),
1370
1423
  onAttachInput: handleAttachInputChange,
1371
1424
  onContextRefresh: () => loadContextFiles({ force: true }),
1372
- onModelConfigToggle: () => toggleModelConfigPopover(),
1425
+ onModelConfigToggle: () => { contextUsageView?.close(); toggleModelConfigPopover(); },
1373
1426
  onModelConfigChoiceClick: event => {
1374
1427
  handleModelConfigChoiceClick(event).catch(error => {
1375
1428
  appendPlainLog(tx(`Failed to update model settings: ${error.message}`, `更新模型设置失败:${error.message}`));
@@ -1382,6 +1435,18 @@
1382
1435
  }
1383
1436
  });
1384
1437
 
1438
+ contextUsageView = Modules.ContextUsageView.create({
1439
+ getPanel: () => panel, getLocale, tr,
1440
+ onOpen: () => {
1441
+ closeModelConfigPopover();
1442
+ closeContextTray();
1443
+ closeSlashMenu();
1444
+ },
1445
+ getCompactAvailability,
1446
+ level: Modules.ContextUsage.level,
1447
+ onCompact: () => safeRunTask({ operation: 'compact' })
1448
+ });
1449
+ renderContextUsage();
1385
1450
  installContextDismiss();
1386
1451
  installModelConfigDismiss();
1387
1452
  bindLogAutoFollow();
@@ -1559,7 +1624,7 @@
1559
1624
  panelRendererInstance?.setLocale?.({ launcher: tx('Show or hide Codex panel', '显示或隐藏 Codex 边栏'), close: tx('Close Codex panel', '关闭 Codex 边栏') });
1560
1625
 
1561
1626
  setElementTitleAndAria('[data-panel-resize-handle]', tr('resizePanel'), tr('resizePanel'));
1562
- setElementTitleAndAria('[data-refresh]', tr('refreshProbe'), tr('refreshProbe'));
1627
+ setRefreshProbeLoading(panel?.querySelector('[data-refresh]')?.dataset.loading === 'true');
1563
1628
  // State-dependent tooltip: re-derive from the dot's current health so a
1564
1629
  // locale/session refresh doesn't clobber it back to the generic label.
1565
1630
  setDiagnosticsHealth(panel.querySelector('[data-diagnostics-health-dot]')?.dataset.health || 'unknown');
@@ -1610,7 +1675,7 @@
1610
1675
  }
1611
1676
  const reviewToggle = panel.querySelector('.codex-review-toggle');
1612
1677
  if (reviewToggle) {
1613
- reviewToggle.title = tr('requireReviewingTitle');
1678
+ syncReviewingTooltip();
1614
1679
  }
1615
1680
  const recompileToggle = panel.querySelector('.codex-recompile-toggle');
1616
1681
  if (recompileToggle) {
@@ -1633,7 +1698,11 @@
1633
1698
  updateExperimentalOtMenuStatus();
1634
1699
  renderModelConfigChoices();
1635
1700
  updateModelDisplay();
1701
+ // The header switcher's title and accessible name carry localized text too.
1702
+ applySessionLabel();
1636
1703
  renderSessionList();
1704
+ renderContextUsage();
1705
+ if (panel.querySelector('[data-slash-menu]')?.hidden === false) updateSlashMenuForTaskInput();
1637
1706
  renderContextSelection();
1638
1707
  renderContextSummary();
1639
1708
  renderPendingInputs();
@@ -1810,7 +1879,8 @@
1810
1879
  // Capture the click before any preparation can yield to another draft/session.
1811
1880
  readPanelInputs();
1812
1881
  const queuedInput = options.queuedInput || null;
1813
- const task = String(queuedInput?.text || state.task || '').trim();
1882
+ const compact = !queuedInput && options.operation === 'compact';
1883
+ const task = compact ? '/compact' : String(queuedInput?.text || state.task || '').trim();
1814
1884
  if (!task) {
1815
1885
  appendLog(tx('Enter a task first.', '请先输入任务。'));
1816
1886
  return;
@@ -1818,32 +1888,38 @@
1818
1888
  const submittedSessionId = state.activeSessionId;
1819
1889
  const submittedSession = runController.captureSessionContext(state);
1820
1890
  const submittedThreadId = getActiveSession(state)?.codexThreadId || '';
1891
+ if (compact && !submittedThreadId) {
1892
+ showPluginToast(tr('compactNoThread'), { status: 'info' });
1893
+ return;
1894
+ }
1821
1895
  const submittedDraft = state.task;
1822
1896
  const submittedRetryReplacement = pendingRetryReplacement;
1823
1897
  const submittedWritingStyleScope = { accountScopeId: cachedAccountScopeId, projectId: getCurrentProjectId() };
1824
1898
  const submittedPanelState = {
1825
- mode: state.mode,
1899
+ mode: compact ? 'ask' : state.mode,
1826
1900
  providerId: state.providerId,
1827
1901
  model: state.model,
1828
1902
  reasoningEffort: state.reasoningEffort,
1829
1903
  speedTier: state.speedTier,
1830
1904
  autoRecompile: state.autoRecompile !== false,
1831
1905
  requireReviewing: state.requireReviewing === true,
1832
- focusFiles: getActiveFocusFiles(),
1833
- selectionContext: selectionContextView.capture()
1906
+ focusFiles: compact ? [] : getActiveFocusFiles(),
1907
+ selectionContext: compact ? null : selectionContextView.capture()
1834
1908
  };
1835
1909
  const queuedContext = queuedInput
1836
1910
  ? RunInputQueue.normalizeSubmissionContext(queuedInput.payload?.submissionContext) : null;
1837
1911
  const submittedCustomInstructions = queuedContext ? queuedContext.customInstructions : getCustomInstructionsForCurrentProject();
1838
1912
  const submittedSkillLoadingSettings = queuedContext ? queuedContext.skillLoadingSettings : getSkillLoadingSettings();
1839
- const submittedAttachments = queuedInput ? [] : getComposerAttachmentsForRun();
1840
- const submittedSkillInvocation = queuedContext ? queuedContext.skillInvocation : getComposerSkillInvocationForRun();
1913
+ const submittedAttachments = queuedInput || compact ? [] : getComposerAttachmentsForRun();
1914
+ const submittedSkillInvocation = compact ? null : queuedContext ? queuedContext.skillInvocation : getComposerSkillInvocationForRun();
1841
1915
  let submittedEnabledSkillIds = queuedContext ? queuedContext.enabledCodexOverleafSkillIds : getEnabledCodexOverleafSkillIds();
1842
1916
  const submittedSkillEnabled = { ...state.codexOverleafSkillEnabled };
1843
1917
  const submittedLocalSkillEnabled = { ...state.codexLocalSkillEnabled };
1844
1918
  const submittedSelectionRevision = selectionContextView.getRevision?.();
1845
- let submittedEnabledLocalSkillIds = queuedContext ? queuedContext.enabledCodexLocalSkillIds : [];
1846
- if (!queuedInput && submittedSkillLoadingSettings.loadCodexLocalSkills !== false) {
1919
+ let submittedEnabledLocalSkillIds = queuedContext ? queuedContext.enabledCodexLocalSkillIds : compact ? getEnabledCodexLocalSkillIds() : [];
1920
+ // Capture submission first; a save must not adopt text typed while it waits.
1921
+ if (!queuedInput) await flushComposerDraft({ requireDurable: true });
1922
+ if (!compact && !queuedInput && submittedSkillLoadingSettings.loadCodexLocalSkills !== false) {
1847
1923
  const localSkills = await primeCodexLocalSkillsCatalog({ required: true, force: submittedSkillInvocation?.scope === 'codex-local' });
1848
1924
  submittedEnabledLocalSkillIds = localSkills.filter(skill => Object.hasOwn(submittedLocalSkillEnabled, skill.id)
1849
1925
  ? submittedLocalSkillEnabled[skill.id] !== false : skill.enabled !== false).map(skill => skill.id);
@@ -1853,7 +1929,7 @@
1853
1929
  throw new Error(tx('The selected local Codex skill is unavailable or disabled. Select an enabled skill and try again.',
1854
1930
  '所选 Codex 本地技能不可用或已关闭,请选择已启用的技能后重试。'));
1855
1931
  }
1856
- if (!queuedInput && submittedSkillLoadingSettings.loadCodexOverleafSkills !== false) {
1932
+ if (!compact && !queuedInput && submittedSkillLoadingSettings.loadCodexOverleafSkills !== false) {
1857
1933
  const skills = await primeCodexOverleafSkillsCatalog({ required: true });
1858
1934
  submittedEnabledSkillIds = skills.map(skill => String(skill?.id || '').trim())
1859
1935
  .filter(id => id && submittedSkillEnabled[id] !== false);
@@ -1862,18 +1938,19 @@
1862
1938
  const pendingMirror = getPendingMirrorRefresh?.();
1863
1939
  if (pendingMirror) await pendingMirror.catch(() => {});
1864
1940
  await providerSettingsCoordinator.ensureLoaded();
1865
- if (!queuedInput) await writingStyleSettings.ensureLoaded(true);
1941
+ if (!queuedInput && !compact) await writingStyleSettings.ensureLoaded(true);
1866
1942
  if (submittedWritingStyleScope.accountScopeId !== cachedAccountScopeId
1867
1943
  || submittedWritingStyleScope.projectId !== getCurrentProjectId()) {
1868
1944
  throw new Error(tx('The active project changed before submission. Submit the task again.',
1869
1945
  '提交前活动项目已改变,请重新提交任务。'));
1870
1946
  }
1871
- if (!findSessionById(submittedSessionId) || currentRunView) {
1947
+ if (!findSessionById(submittedSessionId) || currentRunView
1948
+ || (compact && findSessionById(submittedSessionId)?.codexThreadId !== submittedThreadId)) {
1872
1949
  throw new Error(tx('The submitted session changed before submission. Submit the task again.',
1873
1950
  '提交所属会话已改变,请重新提交任务。'));
1874
1951
  }
1875
- submittedPanelState.writingStyle = queuedInput ? null : writingStyleSettings.capture();
1876
- const executionSnapshot = queuedInput
1952
+ submittedPanelState.writingStyle = queuedInput || compact ? null : writingStyleSettings.capture();
1953
+ let executionSnapshot = queuedInput
1877
1954
  ? RunExecutionSnapshot.resolveForExecution(
1878
1955
  RunExecutionSnapshot.fromQueuePayload(queuedInput.payload),
1879
1956
  { catalog: providerSettingsCoordinator.getCatalog() }
@@ -1881,8 +1958,8 @@
1881
1958
  : captureCurrentExecutionSnapshot(submittedPanelState);
1882
1959
  // Freeze submitted run identity before the panel can change during the run.
1883
1960
  const submittedMode = executionSnapshot.mode;
1884
- const submittedRequireReviewing = executionSnapshot.requireReviewing === true;
1885
- const replaceRunId = queuedInput ? '' : runController.resolveRetryReplacement({
1961
+ let submittedRequireReviewing = executionSnapshot.requireReviewing === true;
1962
+ const replaceRunId = queuedInput || compact ? '' : runController.resolveRetryReplacement({
1886
1963
  candidate: submittedRetryReplacement,
1887
1964
  activeSessionId: submittedSessionId,
1888
1965
  runs: findSessionById(submittedSessionId)?.runs,
@@ -1892,26 +1969,78 @@
1892
1969
  runCancellationRequested = false;
1893
1970
  runCancellationController = new AbortController();
1894
1971
  setRunning(true);
1895
- currentRunView = startRunView({
1896
- sessionId: submittedSessionId,
1897
- sessionContext: submittedSession,
1898
- codexThreadId: submittedThreadId,
1899
- task,
1900
- mode: submittedMode,
1901
- model: executionSnapshot.model,
1902
- reasoningEffort: executionSnapshot.reasoningEffort,
1903
- speedTier: executionSnapshot.speedTier,
1904
- executionSnapshot,
1905
- attachments: submittedAttachments,
1906
- skillInvocation: submittedSkillInvocation,
1907
- queueItemId: queuedInput?.id || '',
1908
- replaceRunId
1909
- });
1910
- if (!queuedInput && pendingRetryReplacement === submittedRetryReplacement) pendingRetryReplacement = null;
1911
- const runSessionId = currentRunView.sessionId;
1972
+ const runSessionId = submittedSessionId;
1912
1973
  let runAuditDraft = null;
1913
1974
  let localSkillRestorePoint = null;
1914
1975
  try {
1976
+ currentRunView = startRunView({
1977
+ sessionId: submittedSessionId,
1978
+ sessionContext: submittedSession,
1979
+ codexThreadId: submittedThreadId,
1980
+ operation: compact ? 'compact' : undefined,
1981
+ task,
1982
+ mode: submittedMode,
1983
+ model: executionSnapshot.model,
1984
+ reasoningEffort: executionSnapshot.reasoningEffort,
1985
+ speedTier: executionSnapshot.speedTier,
1986
+ executionSnapshot,
1987
+ attachments: submittedAttachments,
1988
+ skillInvocation: submittedSkillInvocation,
1989
+ queueItemId: queuedInput?.id || '',
1990
+ replaceRunId
1991
+ });
1992
+ // Preparation can await project/audit work before a Native request exists.
1993
+ // Give other pages a live owner to check throughout that interval.
1994
+ const bound = await awaitRunStep(activeTurnControl.bindBrowserRun({
1995
+ requestId: ActiveTurnControl.getStartupRequestId(runSessionId, currentRunView.recordId),
1996
+ projectKey: currentRunView.runProjectId, clientRunId: currentRunView.recordId,
1997
+ sessionId: runSessionId, ownerToken: crypto.randomUUID()
1998
+ }));
1999
+ throwIfRunCancellationRequested();
2000
+ if (!bound) throw new Error(tx('Could not start the task. Reload the page and try again.',
2001
+ '无法启动任务,请刷新页面后重试。'));
2002
+ renderContextUsage();
2003
+ if (!queuedInput && !compact && pendingRetryReplacement === submittedRetryReplacement) pendingRetryReplacement = null;
2004
+ if (compact) {
2005
+ announceCrossTabRunStart();
2006
+ // Only the slash-command text is consumed; a draft kept while compacting from the popover stays.
2007
+ if (options.consumeCompactDraft === true && state.activeSessionId === submittedSessionId
2008
+ && /^\/[^\s/]*$/.test(String(submittedDraft || '').trim())
2009
+ && panel?.querySelector('[data-task]')?.value === submittedDraft) {
2010
+ panel.querySelector('[data-task]').value = '';
2011
+ updateSessionById(submittedSessionId, { task: '' });
2012
+ recordSubmittedDraftConsumption(submittedSessionId, submittedDraft);
2013
+ composerDraftEditedHere = false;
2014
+ composerDraftRevision += 1;
2015
+ autosizeTaskTextarea();
2016
+ closeSlashMenu();
2017
+ syncComposerSendAvailability(true);
2018
+ }
2019
+ appendRunEvent({ title: tr('contextUsageCompacting'), status: 'running',
2020
+ activity: { v: 1, kind: 'lifecycle', state: 'running', target: 'context_compaction' } });
2021
+ renderContextUsage();
2022
+ const response = await awaitRunStep(sendTrackedCodexRun({
2023
+ operation: 'compact', task: '/compact', mode: 'ask',
2024
+ projectId: currentRunView.runProjectId, accountScopeId: currentRunView.runAccountScopeId,
2025
+ clientRunId: currentRunView.recordId, clientSessionId: runSessionId,
2026
+ threadId: submittedThreadId, model: executionSnapshot.model,
2027
+ reasoningEffort: executionSnapshot.reasoningEffort, speedTier: executionSnapshot.speedTier,
2028
+ providerSelection: RunExecutionSnapshot.toProviderSelection(executionSnapshot),
2029
+ locale: getLocale(), ...submittedSkillLoadingSettings,
2030
+ enabledCodexOverleafSkillIds: submittedEnabledSkillIds,
2031
+ enabledCodexLocalSkillIds: submittedEnabledLocalSkillIds
2032
+ }));
2033
+ if (!response?.ok) throw Object.assign(new Error(response?.error?.message || 'Compaction failed'), response?.error);
2034
+ if (response.result?.operation !== 'compact' || response.result?.status !== 'completed'
2035
+ || response.result?.threadId !== submittedThreadId) {
2036
+ throw Object.assign(new Error('Compaction completion was not confirmed'), { code: 'codex_compact_unconfirmed' });
2037
+ }
2038
+ throwIfRunCancellationRequested();
2039
+ appendCompletionReport({ conclusion: tr('compactCompleted'), conclusionKey: 'context_compacted',
2040
+ status: 'completed', mode: 'ask', operations: [], applyResults: [] });
2041
+ await finishRunView(tr('compactCompleted'), 'completed');
2042
+ return;
2043
+ }
1915
2044
  if (queuedInput?.id) {
1916
2045
  await runQueueScheduler?.markExecuting(runSessionId, queuedInput.id, currentRunView.recordId);
1917
2046
  }
@@ -1990,7 +2119,30 @@
1990
2119
  await awaitRunStep(pauseOtWarmMirror('run-start'));
1991
2120
  const writeSafety = await awaitRunStep(preflightWriteSafety({
1992
2121
  mode: submittedMode,
1993
- requireReviewing: submittedRequireReviewing
2122
+ requireReviewing: submittedRequireReviewing,
2123
+ onUntracked: async choice => {
2124
+ submittedRequireReviewing = false;
2125
+ // Keep the effective policy identical in dispatch, recovery and Undo consumers.
2126
+ const view = currentRunView;
2127
+ const record = findRunRecord(view.recordId, view.sessionId);
2128
+ executionSnapshot = RunExecutionSnapshot.applyUntrackedDecision(view, record, choice);
2129
+ if (choice === 'disable') {
2130
+ updateSessionById(view.sessionId, { requireReviewing: false });
2131
+ if (state.activeSessionId === view.sessionId) {
2132
+ state.requireReviewing = false;
2133
+ panel.querySelector('[data-require-reviewing]').checked = false;
2134
+ }
2135
+ }
2136
+ appendRunEvent({ title: tr('reviewingUnavailableRunUntracked'), status: 'completed',
2137
+ technicalDetail: { code: 'reviewing_unavailable', submittedRequireReviewing: true,
2138
+ effectiveRequireReviewing: false, choice } });
2139
+ saveStateSoon(0);
2140
+ syncProbeStatus();
2141
+ runAuditDraft = await finalizeAuditDraftPartial(runAuditDraft, {
2142
+ requireReviewing: false, trackingDecision: choice
2143
+ });
2144
+ await flushQueuedSaveState({ requireDurable: true });
2145
+ }
1994
2146
  }));
1995
2147
  if (!writeSafety.ok) {
1996
2148
  await finalizeAuditRecord(runAuditDraft, {
@@ -2052,7 +2204,7 @@
2052
2204
  fileCount: (project?.files || []).length
2053
2205
  }
2054
2206
  });
2055
- appendCompletionReport({
2207
+ appendCompletionReport({ conclusionKey: 'full_project_unread',
2056
2208
  conclusion: tx('This run did not continue: the full Overleaf project was not read.', '这轮没有继续:没有读到完整的 Overleaf 项目内容。'),
2057
2209
  status: 'blocked',
2058
2210
  operations: [],
@@ -2239,12 +2391,17 @@
2239
2391
  const userChoice = await showThreadResumeFailedPrompt();
2240
2392
  if (userChoice === 'new') {
2241
2393
  updateSessionById(runSessionId, { codexThreadId: '' });
2394
+ renderContextUsage();
2242
2395
  const StorageDb = Modules.StorageDb;
2243
- if (StorageDb) {
2244
- const record = await StorageDb.getRecord('sessions', runSessionId);
2396
+ // A live session goes through the ordinary writer. An envelope
2397
+ // conflict there must not abort the run that is choosing a new thread.
2398
+ if (findSessionById(runSessionId)) {
2399
+ saveStateSoon();
2400
+ } else if (StorageDb) {
2401
+ const record = await StorageDb.getRecord('sessions', runSessionId, { adopt: false });
2245
2402
  if (record) {
2246
2403
  record.codexThreadId = '';
2247
- await StorageDb.putRecord('sessions', record);
2404
+ await StorageDb.putRecord('sessions', record, { adopt: false });
2248
2405
  }
2249
2406
  }
2250
2407
  appendRunEvent({ title: tx('Creating a new Codex conversation thread.', '正在新建 Codex 会话线程。'), status: 'running' });
@@ -2291,15 +2448,20 @@
2291
2448
  // Structured FailureReason (§9.7): split bridge-availability from
2292
2449
  // Codex-side errors. `native_connection_failed` / `native_unavailable`
2293
2450
  // / `native_update_required` indicate the bridge itself is missing,
2294
- // so emit `native_bridge_unavailable` (blocked, retryable). All other
2295
- // native errors are treated as a Codex-side surface error and emit
2296
- // `codex_no_usable_result` (error, retryable).
2451
+ // so emit `native_bridge_unavailable` (blocked, retryable). Recognized
2452
+ // Codex failures retain their specific cause; only unmatched errors
2453
+ // fall back to `codex_no_usable_result` (error, retryable).
2297
2454
  // Map native/codex failures onto their SPECIFIC catalog codes — the
2298
2455
  // timeout/not-found/output-limit entries (and their bilingual next
2299
2456
  // steps) existed but were never emitted, so everything collapsed into
2300
2457
  // "no usable result" and pointed users at the wrong recovery.
2301
2458
  const runErrorMessage = String(response.error?.message || '');
2302
- const codexRunFailure = response.error?.code === 'codex_local_skill_unavailable'
2459
+ const codexRunFailure = translated.failureCode === 'codex_context_limit'
2460
+ ? buildContentFailure('codex_context_limit', { path: 'codex.run' }, {
2461
+ technicalMessage: runErrorMessage,
2462
+ evidence: { errorCode: response.error?.code || '' }
2463
+ })
2464
+ : response.error?.code === 'codex_local_skill_unavailable'
2303
2465
  ? buildContentFailure('codex_local_skill_unavailable', { path: 'codex.run' }, { technicalMessage: runErrorMessage })
2304
2466
  : translated.failureCode === 'codex_local_skills_failed'
2305
2467
  ? buildContentFailure('codex_local_skills_failed', { path: 'codex.run' }, { technicalMessage: runErrorMessage })
@@ -2360,7 +2522,7 @@
2360
2522
  detail: response.error
2361
2523
  });
2362
2524
  appendCompletionReport({
2363
- conclusion: translated.conclusion,
2525
+ conclusion: translated.conclusion, conclusionKey: translated.conclusionKey,
2364
2526
  status: response.error?.code === 'project_locked' ? 'blocked' : 'failed',
2365
2527
  operations: [],
2366
2528
  applyResults: [],
@@ -2410,7 +2572,7 @@
2410
2572
  status: 'failed',
2411
2573
  failure: emptyFailure
2412
2574
  });
2413
- appendCompletionReport({
2575
+ appendCompletionReport({ conclusionKey: 'empty_result',
2414
2576
  conclusion: emptyResultConclusion,
2415
2577
  status: 'failed',
2416
2578
  operations: [],
@@ -2514,12 +2676,14 @@
2514
2676
  if (returnedThreadId && returnedThreadId !== codexThreadId) {
2515
2677
  updateSessionById(runSessionId, { codexThreadId: returnedThreadId });
2516
2678
  const StorageDb = Modules.StorageDb;
2517
- if (StorageDb) {
2518
- const record = await StorageDb.getRecord('sessions', runSessionId);
2679
+ // The live session is saved below with its latest draft. Writing an
2680
+ // older DB snapshot here can give stale text a newer timestamp.
2681
+ if (StorageDb && !findSessionById(runSessionId)) {
2682
+ const record = await StorageDb.getRecord('sessions', runSessionId, { adopt: false });
2519
2683
  if (record) {
2520
2684
  record.codexThreadId = returnedThreadId;
2521
2685
  record.updatedAt = new Date().toISOString();
2522
- await StorageDb.putRecord('sessions', record);
2686
+ await StorageDb.putRecord('sessions', record, { adopt: false });
2523
2687
  }
2524
2688
  }
2525
2689
  }
@@ -2540,17 +2704,43 @@
2540
2704
  }
2541
2705
  } catch (error) {
2542
2706
  if (runCancellationRequested || isRunCancellationError(error)) {
2543
- appendRunCancelledReport();
2707
+ if (currentRunView?.reviewingUnavailablePreflight) {
2708
+ appendCompletionReport({ conclusionKey: 'launch_not_started',
2709
+ conclusion: tx('This task did not start: Codex did not run and no files were written.', '这轮任务没有开始:Codex 没有运行,也没有写入文件。'),
2710
+ status: 'blocked', operations: [], applyResults: [],
2711
+ failure: buildContentFailure('reviewing_unavailable', {}, {
2712
+ changedDocument: false, evidence: { declinedChoice: true }
2713
+ }),
2714
+ nextStep: tr('failureReason_reviewing_unavailable_cancelled_next') });
2715
+ } else appendRunCancelledReport();
2544
2716
  await finishRunView(tx('Cancelled', '已取消'), 'cancelled');
2545
2717
  void finalizeAuditRecord(runAuditDraft, { resultStatus: 'cancelled' });
2546
2718
  return;
2547
2719
  }
2720
+ if (compact) {
2721
+ const key = error.code === 'codex_compact_unsupported' ? 'compactUnsupported'
2722
+ : ['project_locked', 'codex_compact_thread_busy'].includes(error.code) ? 'compactBusy' : 'compactFailed';
2723
+ const contextLimit = FailureReasons.isContextLimitError(error.message, error.code)
2724
+ ? translateRawError(error.message, { locale: getLocale(), code: error.code }) : null;
2725
+ const failure = contextLimit ? buildContentFailure('codex_context_limit', { path: 'codex.run' }, {
2726
+ technicalMessage: error.message, evidence: { errorCode: error.code || '' }
2727
+ }) : undefined;
2728
+ const conclusion = contextLimit?.conclusion || tr(key);
2729
+ appendRunEvent({ title: conclusion, status: 'failed', failure,
2730
+ technicalDetail: { code: error.code || '', message: error.message } });
2731
+ appendCompletionReport({ conclusion, conclusionKey: contextLimit?.conclusionKey || (key === 'compactUnsupported'
2732
+ ? 'compact_unsupported' : key === 'compactBusy' ? 'compact_busy' : 'compact_failed'),
2733
+ nextStep: contextLimit?.nextStep, failure,
2734
+ status: 'failed', mode: 'ask', operations: [], applyResults: [] });
2735
+ await finishRunView(conclusion, 'failed');
2736
+ return;
2737
+ }
2548
2738
  // codexReturned: assistantMessage arrived on the stream before the
2549
2739
  // exception escaped, so Codex's answer is preserved in the conversation
2550
2740
  // — the fallback conclusion must reflect that instead of saying
2551
2741
  // 'local Codex returned no usable result'.
2552
2742
  const codexReturned = Boolean(getAssistantAnswerForCurrentRun());
2553
- const translated = translateRawError(error.message, { mode: submittedMode, locale: getLocale(), codexReturned });
2743
+ const translated = translateRawError(error.message, { mode: submittedMode, locale: getLocale(), code: error.code, codexReturned });
2554
2744
  // When translateRawError maps the raw error to a FailureReason code,
2555
2745
  // attach the structured failure to the run-event and completion-report
2556
2746
  // so the run record carries machine-readable failure data alongside
@@ -2578,7 +2768,7 @@
2578
2768
  }
2579
2769
  });
2580
2770
  appendCompletionReport({
2581
- conclusion: translated.conclusion,
2771
+ conclusion: translated.conclusion, conclusionKey: translated.conclusionKey,
2582
2772
  status: 'failed',
2583
2773
  operations: [],
2584
2774
  applyResults: [],
@@ -2592,6 +2782,10 @@
2592
2782
  blockedFiles: [{ path: 'task', reason: error.message }]
2593
2783
  });
2594
2784
  await finishRunView(tx('Task failed', '任务失败'), 'failed');
2785
+ // A failed initial card render must still leave a visible terminal record.
2786
+ if (currentRunView?.sessionId === state.activeSessionId && !getCurrentRunViewForRender()) {
2787
+ renderRunHistory({ preserveScroll: true });
2788
+ }
2595
2789
  } finally {
2596
2790
  const settledView = currentRunView;
2597
2791
  const settledRecord = settledView ? findRunRecord(settledView.recordId, settledView.sessionId) : null;
@@ -2647,6 +2841,7 @@
2647
2841
  reasoningEffort: executionSnapshot.reasoningEffort,
2648
2842
  speedTier: executionSnapshot.speedTier,
2649
2843
  session: currentRunView?.sessionContext,
2844
+ locale: getLocale(),
2650
2845
  clientRunId: currentRunView?.recordId || '',
2651
2846
  clientSessionId: runSessionId,
2652
2847
  accountScopeId: currentRunView?.runAccountScopeId || '',
@@ -2688,7 +2883,7 @@
2688
2883
  failure: translatedFailure
2689
2884
  });
2690
2885
  appendCompletionReport({
2691
- conclusion: translated.conclusion,
2886
+ conclusion: translated.conclusion, conclusionKey: translated.conclusionKey,
2692
2887
  status: 'failed',
2693
2888
  operations: [],
2694
2889
  applyResults: [],
@@ -2706,7 +2901,7 @@
2706
2901
  }
2707
2902
 
2708
2903
  const assistantMessage = response.result?.assistantMessage || getAssistantAnswerForCurrentRun();
2709
- appendCompletionReport({
2904
+ appendCompletionReport({ conclusionKey: !assistantMessage ? 'skill_installer_completed' : undefined,
2710
2905
  conclusion: assistantMessage || tx('Skill installer completed.', 'Skill installer 已完成。'),
2711
2906
  status: 'completed',
2712
2907
  operations: [],
@@ -2740,11 +2935,14 @@
2740
2935
  if (returnedThreadId && returnedThreadId !== codexThreadId) {
2741
2936
  updateSessionById(runSessionId, { codexThreadId: returnedThreadId });
2742
2937
  const StorageDb = Modules.StorageDb;
2743
- if (StorageDb) {
2744
- const record = await StorageDb.getRecord('sessions', runSessionId);
2938
+ // The installer already finished; a later envelope conflict only delays this save.
2939
+ if (findSessionById(runSessionId)) {
2940
+ saveStateSoon();
2941
+ } else if (StorageDb) {
2942
+ const record = await StorageDb.getRecord('sessions', runSessionId, { adopt: false });
2745
2943
  if (record) {
2746
2944
  record.codexThreadId = returnedThreadId;
2747
- await StorageDb.putRecord('sessions', record);
2945
+ await StorageDb.putRecord('sessions', record, { adopt: false });
2748
2946
  }
2749
2947
  }
2750
2948
  }
@@ -2758,8 +2956,6 @@
2758
2956
  if (mode === 'ask') {
2759
2957
  return { ok: true, skipped: true };
2760
2958
  }
2761
- const method = requireReviewing ? 'ensureReviewing' : 'ensureEditing';
2762
-
2763
2959
  appendRunEvent({
2764
2960
  title: requireReviewing
2765
2961
  ? tx('Checking Overleaf Reviewing/Track Changes before starting.', '正在确认 Overleaf 留痕状态。')
@@ -2767,30 +2963,67 @@
2767
2963
  status: 'running'
2768
2964
  });
2769
2965
 
2770
- let result = null;
2771
- try {
2772
- // Thread runProjectId through so the page-bridge dispatcher applies the
2773
- // writeGuard. Without it, a mid-flight SPA navigation could leave the
2774
- // pre-flight ensureReviewing/Editing toggle landing in the wrong
2775
- // project — Track Changes flipped on/off in a project the user did
2776
- // not intend to write to.
2777
- result = await callPageBridge(method, {
2778
- waitMs: 1800,
2779
- runProjectId: currentRunView?.runProjectId || ''
2780
- });
2781
- } catch (error) {
2782
- result = {
2783
- ok: false,
2784
- reason: error?.message || (requireReviewing
2785
- ? tx('Overleaf did not return track changes status', 'Overleaf 没有返回留痕状态')
2786
- : tx('Overleaf did not return Editing mode status', 'Overleaf 没有返回 Editing 模式状态')),
2787
- reviewing: null
2788
- };
2966
+ const owner = currentRunView;
2967
+ const signal = runCancellationController?.signal;
2968
+ const isCurrent = () => owner === currentRunView && !signal?.aborted && !runCancellationRequested
2969
+ && owner?.runProjectId === getCurrentProjectId() && owner?.runAccountScopeId === cachedAccountScopeId;
2970
+ const assertCurrent = () => { if (!isCurrent()) throw createRunCancellationError(); };
2971
+ assertCurrent();
2972
+ if (requireReviewing) {
2973
+ // Another tab may have observed this project's capability since hydration.
2974
+ const prefs = await Modules.StorageMigration.loadPrefs(owner.runAccountScopeId, owner.runProjectId);
2975
+ assertCurrent();
2976
+ const incoming = prefs?.reviewingAvailabilityByProject?.[owner.runProjectId];
2977
+ const current = projectSettingsCoordinator.getReviewingAvailability();
2978
+ if (incoming && (!current || incoming.observedAt > current.observedAt
2979
+ || (incoming.observedAt === current.observedAt && incoming.status === 'available'))) {
2980
+ state = { ...state, reviewingAvailabilityByProject: {
2981
+ ...state.reviewingAvailabilityByProject, [owner.runProjectId]: incoming
2982
+ } };
2983
+ syncProbeStatus();
2984
+ }
2789
2985
  }
2986
+ let result = await Modules.Reviewing.resolveWritePolicy({ requireReviewing,
2987
+ knownUnavailable: isReviewingKnownUnavailable() }, {
2988
+ assertCurrent,
2989
+ ensureMode: async required => {
2990
+ try {
2991
+ return await callPageBridge(required ? 'ensureReviewing' : 'ensureEditing', {
2992
+ waitMs: 1800, runProjectId: owner.runProjectId
2993
+ });
2994
+ } catch (error) {
2995
+ assertCurrent();
2996
+ return { ok: false, code: error?.code, reason: error?.message, reviewing: null };
2997
+ }
2998
+ },
2999
+ observe: async (status, evidence) => {
3000
+ projectSettingsCoordinator.observeReviewingAvailability(status, evidence);
3001
+ syncProbeStatus();
3002
+ // Publish a detected paywall before waiting for the user's decision.
3003
+ if (status === 'unavailable') await flushQueuedSaveState({ requireDurable: true });
3004
+ assertCurrent();
3005
+ },
3006
+ choose: () => {
3007
+ owner.reviewingUnavailablePreflight = true;
3008
+ return showPluginConfirm({
3009
+ title: tr('reviewingUnavailableTitle'),
3010
+ message: tr('reviewingUnavailableChoiceMessage'),
3011
+ choices: [
3012
+ { value: 'continue', label: tr('reviewingUnavailableContinue') },
3013
+ { value: 'disable', label: tr('reviewingUnavailableDisable'), secondary: true },
3014
+ { value: 'cancel', label: tr('confirmDefaultCancel') }
3015
+ ],
3016
+ signal, isCurrent, timeoutMs: REVIEWING_CONFIRM_TIMEOUT_MS
3017
+ });
3018
+ },
3019
+ applyUntracked: choice => { assertCurrent(); return options.onUntracked(choice); }
3020
+ });
3021
+ if (result.ok) owner.reviewingUnavailablePreflight = false;
3022
+ const effectiveRequireReviewing = result.requireReviewing ?? requireReviewing;
2790
3023
 
2791
3024
  if (result?.ok) {
2792
3025
  appendRunEvent({
2793
- title: requireReviewing
3026
+ title: effectiveRequireReviewing
2794
3027
  ? (result.activated
2795
3028
  ? tx('Overleaf Reviewing/Track Changes is now on. Starting the task.', '已开启 Overleaf 留痕,开始处理任务。')
2796
3029
  : tx('Overleaf Reviewing/Track Changes is already on. Starting the task.', 'Overleaf 留痕已经开启,开始处理任务。'))
@@ -2802,10 +3035,23 @@
2802
3035
  return result;
2803
3036
  }
2804
3037
 
2805
- const reason = localizeVisibleReason(result?.reason || (requireReviewing
3038
+ const unavailable = result?.code === 'reviewing_unavailable';
3039
+ if (unavailable && !result.failure) result.failure = buildContentFailure('reviewing_unavailable', {}, {
3040
+ changedDocument: false, evidence: result.evidence
3041
+ });
3042
+ const reason = unavailable ? tr('failureReason_reviewing_unavailable_user') : localizeVisibleReason(result?.reason || (effectiveRequireReviewing
2806
3043
  ? tx('Overleaf did not return track changes status', 'Overleaf 没有返回留痕状态')
2807
3044
  : tx('Overleaf did not return Editing mode status', 'Overleaf 没有返回 Editing 模式状态')));
2808
- const nextStep = requireReviewing
3045
+ // Affirmative choices retain trackingDecision even if the Editing check fails.
3046
+ const declinedReviewingChoice = owner.reviewingUnavailablePreflight === true && !result.trackingDecision;
3047
+ const reportFailure = unavailable && declinedReviewingChoice
3048
+ ? buildContentFailure('reviewing_unavailable', {}, {
3049
+ ...result.failure, changedDocument: false,
3050
+ evidence: { ...result.failure?.evidence, declinedChoice: true }
3051
+ }) : result?.failure;
3052
+ const nextStep = unavailable ? tr(declinedReviewingChoice
3053
+ ? 'failureReason_reviewing_unavailable_cancelled_next'
3054
+ : 'failureReason_reviewing_unavailable_next') : effectiveRequireReviewing
2809
3055
  ? tx(
2810
3056
  'You may not have permission, or this Overleaf page did not expose the Reviewing switch. Switch to Reviewing manually and retry, or turn off Track before writing.',
2811
3057
  '你可能没有权限,或 Overleaf 当前页面没有暴露切换入口。可以手动切到 Reviewing 后重试,或关闭“留痕”再运行。'
@@ -2815,7 +3061,7 @@
2815
3061
  '你可能没有权限,或 Overleaf 当前页面没有暴露切换入口。请在 Overleaf 手动切到 Editing 后重试。'
2816
3062
  );
2817
3063
  appendRunEvent({
2818
- title: requireReviewing
3064
+ title: effectiveRequireReviewing
2819
3065
  ? tx('Task not started: could not enable Overleaf Reviewing/Track Changes.', '任务未开始:无法开启 Overleaf 留痕。')
2820
3066
  : tx('Task not started: could not switch to Overleaf Editing.', '任务未开始:无法切换到 Overleaf Editing。'),
2821
3067
  status: 'failed',
@@ -2824,24 +3070,25 @@
2824
3070
  [tr('detailNext')]: nextStep
2825
3071
  },
2826
3072
  technicalDetail: {
2827
- reason,
3073
+ reason, code: result?.code, evidence: result?.evidence, failure: result?.failure,
2828
3074
  reviewing: result?.reviewing || null
2829
3075
  }
2830
3076
  });
2831
- appendCompletionReport({
3077
+ appendCompletionReport({ conclusionKey: 'launch_not_started',
2832
3078
  conclusion: tx('This task did not start: Codex did not run and no files were written.', '这轮任务没有开始:Codex 没有运行,也没有写入文件。'),
2833
3079
  status: 'blocked',
2834
3080
  operations: [],
2835
3081
  applyResults: [],
2836
- nextStep
3082
+ nextStep,
3083
+ reason: result?.code, failure: reportFailure
2837
3084
  });
2838
- const finishTitle = requireReviewing
3085
+ const finishTitle = effectiveRequireReviewing
2839
3086
  ? tx('Not started: could not enable Track Changes', '未开始:无法开启留痕')
2840
3087
  : tx('Not started: could not switch to Editing', '未开始:无法切换到 Editing');
2841
3088
  await finishRunView(finishTitle, 'failed');
2842
3089
  return {
2843
- ok: false,
2844
- reason,
3090
+ ...result, ok: false,
3091
+ reason: result?.code || reason,
2845
3092
  reviewing: result?.reviewing || null
2846
3093
  };
2847
3094
  }
@@ -2854,17 +3101,25 @@
2854
3101
  return runController.truncateSessionHistoryText(value, maxLength);
2855
3102
  }
2856
3103
 
2857
- function safeRunTask() {
3104
+ function safeRunTask(options = {}) {
3105
+ if (sessionManager.isTransitioning()) return;
2858
3106
  if (updateIdleClient?.isApplying()) {
2859
3107
  showPluginToast(tx('An update is being applied. Codex will be available again after reload.', '正在应用更新,重新加载后即可继续使用 Codex。'), { status: 'info' });
2860
3108
  return;
2861
3109
  }
3110
+ const submittedCompactCommand = options.operation !== 'compact'
3111
+ && panel?.querySelector('[data-task]')?.value.trim() === '/compact';
3112
+ const compact = options.operation === 'compact' || submittedCompactCommand;
3113
+ if (compact && (currentRunView || runSubmissionPending)) {
3114
+ showPluginToast(tr('compactBusy'), { status: 'info' });
3115
+ return;
3116
+ }
2862
3117
  if (currentRunView) {
2863
3118
  return queueComposerInput();
2864
3119
  }
2865
3120
  if (runSubmissionPending) return;
2866
3121
  runSubmissionPending = true;
2867
- runTask().catch(error => {
3122
+ runTask(compact ? { operation: 'compact', consumeCompactDraft: options.consumeCompactDraft === true || submittedCompactCommand } : {}).catch(error => {
2868
3123
  if (!currentRunView) {
2869
3124
  setRunning(false);
2870
3125
  nativeChannel.clearActiveRequest();
@@ -2896,7 +3151,7 @@
2896
3151
  getActiveSession: () => getActiveSession(state),
2897
3152
  setQueue: (sessionId, pendingInputs) => updateSessionById(sessionId, { pendingInputs }),
2898
3153
  getCurrentRun: () => currentRunView,
2899
- canStart: sessionId => state?.activeSessionId === sessionId && isProjectEditorRoute(window.location),
3154
+ canStart: canStartQueuedSession,
2900
3155
  readInputs: readPanelInputs,
2901
3156
  getTask: () => panel?.querySelector('[data-task]')?.value || '',
2902
3157
  hasAttachments: () => getComposerAttachmentsForRun().length > 0,
@@ -2916,6 +3171,7 @@
2916
3171
  }),
2917
3172
  clearTask: () => {
2918
3173
  panel.querySelector('[data-task]').value = '';
3174
+ composerDraftEditedHere = false;
2919
3175
  selectionContextView.clear();
2920
3176
  updateSessionById(state.activeSessionId, { task: '' });
2921
3177
  autosizeTaskTextarea();
@@ -2926,6 +3182,8 @@
2926
3182
  RunExecutionSnapshot.fromQueuePayload(item.payload),
2927
3183
  { catalog: providerSettingsCoordinator.getCatalog() }
2928
3184
  );
3185
+ // Promotion uses its captured snapshot; this line still describes the current draft.
3186
+ syncProbeStatus();
2929
3187
  },
2930
3188
  run: item => runTask({ queuedInput: item }),
2931
3189
  getGuideScope: () => ({ projectId: getCurrentProjectId(), accountScopeId: cachedAccountScopeId, generation: spaRouteGeneration }),
@@ -2978,7 +3236,17 @@
2978
3236
  });
2979
3237
  }
2980
3238
 
3239
+ function canStartQueuedSession(sessionId) {
3240
+ if (state?.activeSessionId !== sessionId || !isProjectEditorRoute(window.location)) return false;
3241
+ if (sessionManager.isTransitioning()) {
3242
+ deferredSessionStart = { sessionId, scope: getComposerDraftScope() };
3243
+ return false;
3244
+ }
3245
+ return true;
3246
+ }
3247
+
2981
3248
  function queueComposerInput() {
3249
+ if (sessionManager.isTransitioning()) return;
2982
3250
  if (getSkillLoadingSettings().loadCodexLocalSkills !== false && !Array.isArray(getCodexLocalSkillsCatalog())) {
2983
3251
  primeCodexLocalSkillsCatalog({ required: true }).catch(() => {});
2984
3252
  showPluginToast(tx('Local Codex skills are still loading. Try submitting again when loading finishes.',
@@ -3218,6 +3486,7 @@
3218
3486
  const params = runController.buildCodexRunParams({
3219
3487
  currentProjectId: getCurrentProjectId(),
3220
3488
  state: submittedState,
3489
+ locale: getLocale(),
3221
3490
  task,
3222
3491
  project,
3223
3492
  useExistingMirror,
@@ -3271,6 +3540,9 @@
3271
3540
  if (!compatibilityGate.ok) {
3272
3541
  return compatibilityGate.response;
3273
3542
  }
3543
+ if (params.operation === 'compact' && compatibilityGate.compatibility?.native?.capabilities?.threadCompact !== true) {
3544
+ return { ok: false, error: { code: 'codex_compact_unsupported', message: 'This Native host does not support context compaction.' } };
3545
+ }
3274
3546
  const tracked = nativeChannel.sendNativeTracked(nativeCompatibilityController.attachEvidence({
3275
3547
  method: 'codex.run',
3276
3548
  params
@@ -3298,58 +3570,44 @@
3298
3570
  }
3299
3571
 
3300
3572
  async function recoverInterruptedRunJournals() {
3301
- const projectKey = getCurrentProjectId();
3573
+ const projectKey = getCurrentProjectId(), accountScopeId = cachedAccountScopeId;
3574
+ const generation = spaRouteGeneration;
3575
+ const isCurrent = () => generation === spaRouteGeneration
3576
+ && projectKey === getCurrentProjectId() && accountScopeId === cachedAccountScopeId;
3302
3577
  const recovery = await activeTurnControl.recoverJournals({
3303
3578
  projectKey,
3304
- accountScopeId: cachedAccountScopeId,
3579
+ accountScopeId,
3580
+ isCurrent,
3581
+ onRecheck: async () => {
3582
+ if (!isCurrent()) return;
3583
+ await recoverInterruptedRunJournals();
3584
+ if (isCurrent()) applyStateToPanel();
3585
+ },
3305
3586
  requireOwnerLost: Modules.StorageDb.sharedSessionsEnabled?.() === true,
3306
3587
  normalizeInterruptedRun: record => Object.assign(record, Modules.SessionState.normalizeRuns([record], {
3307
3588
  restoreRunningRuns: true,
3308
3589
  locale: getLocale()
3309
3590
  })[0]),
3310
3591
  findSession: findSessionById,
3592
+ listSessions: () => state?.sessions || [],
3593
+ isLiveRun: (record, session) => currentRunView?.recordId === record.id
3594
+ && currentRunView.sessionId === session.id && currentRunView.runProjectId === projectKey,
3311
3595
  getActiveSession: () => getActiveSession(state),
3312
- createInterruptedRun: journal => ({
3313
- id: journal.clientRunId,
3314
- task: journal.task || 'interrupted task',
3315
- mode: journal.mode || 'ask',
3316
- model: journal.model || '',
3317
- status: 'interrupted',
3318
- statusText: tr('restoredRunStoppedStatus'),
3319
- runProjectId: projectKey,
3320
- startedAt: journal.createdAt || new Date().toISOString(),
3321
- finishedAt: journal.updatedAt || new Date().toISOString(),
3322
- events: [],
3323
- attachments: [],
3324
- undoOperations: [],
3325
- undoTrackedChanges: [],
3326
- undoExpectedFiles: []
3596
+ createInterruptedRun: journal => ActiveTurnControl.createInterruptedRun(journal, projectKey, tr('restoredRunStoppedStatus')),
3597
+ onRawEvent: (raw, { record, session, sequence }) => storeContextUsage(raw, record, session, sequence),
3598
+ mapEvent: (raw, journal) => ActiveTurnControl.mapRecoveredEvent(raw, journal, {
3599
+ mapActivity: value => mapAgentEventToActivity(value, { locale: getLocale() }),
3600
+ sanitizeText: sanitizeAssistantVisibleText, sanitizeValue: sanitizeAssistantVisibleValue
3327
3601
  }),
3328
- mapEvent: (raw, journal) => {
3329
- const activity = mapAgentEventToActivity(raw, { locale: getLocale() });
3330
- if (!activity?.visible || activity.kind === 'technical') {
3331
- return null;
3332
- }
3333
- return {
3334
- kind: activity.kind || 'activity',
3335
- title: sanitizeAssistantVisibleText(activity.title) || 'Event',
3336
- status: activity.status || 'running',
3337
- detail: sanitizeAssistantVisibleValue(activity.detail),
3338
- timestamp: raw.timestamp || journal.updatedAt || new Date().toISOString(),
3339
- streamKey: sanitizeAssistantVisibleText(activity.streamKey),
3340
- streamRole: sanitizeAssistantVisibleText(activity.streamRole),
3341
- appendText: sanitizeAssistantVisibleText(activity.appendText),
3342
- replaceText: activity.replaceText
3343
- };
3344
- },
3345
3602
  upsertStream: upsertRunStreamRecordEvent,
3346
3603
  pauseSessionQueue: (session, reason) => {
3347
3604
  session.pendingInputs = RunInputQueue.pauseAll(session.pendingInputs || [], reason);
3348
3605
  },
3349
3606
  maxEvents: MAX_RUN_EVENTS
3350
3607
  });
3608
+ if (!isCurrent()) return;
3351
3609
  if (recovery.changed) {
3352
- if (await saveState({ recoveryRuns: recovery.recoveredRuns }) !== true) return;
3610
+ if (await saveState({ recoveryRuns: recovery.recoveredRuns }) !== true || !isCurrent()) return;
3353
3611
  for (const requestId of recovery.acknowledgeIds) {
3354
3612
  await activeTurnControl.acknowledge(requestId).catch(() => false);
3355
3613
  }
@@ -3375,6 +3633,7 @@
3375
3633
  sessionId: input.sessionId || '',
3376
3634
  turnId: currentRunView?.recordId || '',
3377
3635
  mode: input.mode || executionSnapshot.mode,
3636
+ requireReviewing: executionSnapshot.requireReviewing,
3378
3637
  ...providerSettingsCoordinator.getProviderSnapshot(executionSnapshot.providerId),
3379
3638
  providerRevision: executionSnapshot.providerRevision,
3380
3639
  model: executionSnapshot.model,
@@ -3453,7 +3712,7 @@
3453
3712
  status: 'info',
3454
3713
  activity: { v: 1, kind: 'lifecycle', state: 'cancelled', target: 'cancelled' }
3455
3714
  });
3456
- appendCompletionReport({
3715
+ appendCompletionReport({ conclusionKey: 'cancelled',
3457
3716
  conclusion: tx('This run was cancelled. It did not continue syncing or writing to Overleaf.', '这轮已中断,没有继续同步或写入 Overleaf。'),
3458
3717
  status: 'cancelled',
3459
3718
  operations: [],
@@ -3482,7 +3741,8 @@
3482
3741
  const running = typeof runningOverride === 'boolean'
3483
3742
  ? runningOverride
3484
3743
  : Boolean(currentRunView);
3485
- const action = running && !hasInput ? 'cancel' : 'send';
3744
+ const transitioning = sessionManager.isTransitioning();
3745
+ const action = running && (!hasInput || transitioning) ? 'cancel' : 'send';
3486
3746
  const label = action === 'cancel'
3487
3747
  ? tr('cancelRun')
3488
3748
  : running
@@ -3491,13 +3751,17 @@
3491
3751
  runButton.dataset.action = action;
3492
3752
  runButton.disabled = action === 'cancel'
3493
3753
  ? runCancellationRequested
3494
- : !hasInput;
3754
+ : !hasInput || transitioning;
3495
3755
  runButton.title = label;
3496
3756
  runButton.setAttribute('aria-label', label);
3497
- if (currentRunView?.writebackRetry && hasInput) runButton.disabled = true;
3757
+ if (currentRunView?.writebackRetry && hasInput && action !== 'cancel') runButton.disabled = true;
3498
3758
  }
3499
3759
 
3500
3760
  function handleTaskInputKeydown(event) {
3761
+ if (sessionManager.isTransitioning()) {
3762
+ if (event.key === 'Enter' && !event.isComposing && event.keyCode !== 229) event.preventDefault();
3763
+ return;
3764
+ }
3501
3765
  if (handleSlashMenuKeydown(event)) {
3502
3766
  return;
3503
3767
  }
@@ -3525,9 +3789,12 @@
3525
3789
 
3526
3790
  function getSlashCommands() {
3527
3791
  const commands = [
3792
+ { id: 'compact', kind: 'compact', group: 'commands', title: tr('slashCompactTitle'), slug: '/compact',
3793
+ subtitle: tr('slashCompactSubtitle') },
3528
3794
  {
3529
3795
  id: 'install-skill',
3530
3796
  kind: 'installer',
3797
+ group: 'commands',
3531
3798
  title: tr('slashInstallSkillTitle'),
3532
3799
  subtitle: tr('slashInstallSkillSubtitle')
3533
3800
  }
@@ -3546,6 +3813,7 @@
3546
3813
  commands.push({
3547
3814
  id: `skill:${id}`,
3548
3815
  kind: 'codex-overleaf-skill',
3816
+ group: 'overleaf',
3549
3817
  scope: 'codex-overleaf',
3550
3818
  skillId: id,
3551
3819
  title,
@@ -3556,11 +3824,13 @@
3556
3824
  for (const skill of localSkills) {
3557
3825
  if (!isSafeSkillId(skill.id) || !isCodexLocalSkillEnabled(skill.id, skill)) continue;
3558
3826
  const sameName = localSkills.filter(other => String(other.title || other.name || other.id) === String(skill.title || skill.name || skill.id)).length > 1;
3559
- commands.push({ id: `local-skill:${skill.id}`, kind: 'codex-local-skill', scope: 'codex-local', skillId: skill.id,
3827
+ // Source and duplicate hints lead so a truncated preview cannot hide them.
3828
+ commands.push({ id: `local-skill:${skill.id}`, kind: 'codex-local-skill', group: 'local', scope: 'codex-local', skillId: skill.id,
3560
3829
  title: String(skill.title || skill.name || skill.id).trim().slice(0, 80),
3561
3830
  searchName: String(skill.name || ''),
3562
- subtitle: tr('slashUseLocalSkillSubtitle') + (skill.sourceKind === 'plugin' && skill.sourceLabel ? ' · ' + skill.sourceLabel : '')
3563
- + (sameName ? ' · ' + (skill.sourceHint || skill.id.slice(-8)) : '') });
3831
+ subtitle: [skill.sourceKind === 'plugin' && skill.sourceLabel ? skill.sourceLabel : '',
3832
+ sameName ? skill.sourceHint || skill.id.slice(-8) : '',
3833
+ String(skill.preview || '').trim() || tr('slashUseLocalSkillSubtitle')].filter(Boolean).join(' · ') });
3564
3834
  }
3565
3835
  return commands;
3566
3836
  }
@@ -3607,7 +3877,7 @@
3607
3877
  || command.id.toLowerCase().includes(query)
3608
3878
  || String(command.searchName || '').toLowerCase().includes(query);
3609
3879
  });
3610
- renderSlashMenu(commands);
3880
+ renderSlashMenu(commands, { query });
3611
3881
  return;
3612
3882
  }
3613
3883
  const atTrigger = getAtFileTrigger();
@@ -3705,7 +3975,7 @@
3705
3975
  closeSlashMenu();
3706
3976
  return;
3707
3977
  }
3708
- renderSlashMenu(entries);
3978
+ renderSlashMenu(entries, { query });
3709
3979
  }
3710
3980
 
3711
3981
  // Lazy one-shot load: the tray's file list is usually warm (panel open
@@ -3788,7 +4058,22 @@
3788
4058
  };
3789
4059
  }
3790
4060
 
3791
- function renderSlashMenu(commands = []) {
4061
+ const SLASH_MENU_GROUPS = { commands: 'slashGroupCommands', overleaf: 'slashGroupOverleafSkills', local: 'slashGroupLocalSkills' };
4062
+ const SLASH_MENU_ICONS = { compact: 'compact', installer: 'install', 'codex-overleaf-skill': 'book', 'codex-local-skill': 'terminal' };
4063
+
4064
+ function appendHighlightedText(node, value, query) {
4065
+ const text = String(value || '');
4066
+ const at = query ? text.toLowerCase().indexOf(query) : -1;
4067
+ if (at < 0) {
4068
+ node.append(text);
4069
+ return;
4070
+ }
4071
+ const mark = document.createElement('mark');
4072
+ mark.textContent = text.slice(at, at + query.length);
4073
+ node.append(text.slice(0, at), mark, text.slice(at + query.length));
4074
+ }
4075
+
4076
+ function renderSlashMenu(commands = [], { query = '' } = {}) {
3792
4077
  const menu = panel?.querySelector('[data-slash-menu]');
3793
4078
  if (!menu) {
3794
4079
  return;
@@ -3798,7 +4083,17 @@
3798
4083
  menu.hidden = true;
3799
4084
  return;
3800
4085
  }
4086
+ const usage = commands.some(command => command.kind === 'compact') ? getContextUsageDisplay().usage : null;
4087
+ const percent = usage?.contextWindow > 0 ? usage.usedTokens / usage.contextWindow * 100 : null;
4088
+ let group = '';
3801
4089
  commands.forEach((command, index) => {
4090
+ if (command.group && command.group !== group) {
4091
+ group = command.group;
4092
+ const label = document.createElement('div');
4093
+ label.className = 'codex-slash-menu-group';
4094
+ label.textContent = tr(SLASH_MENU_GROUPS[group]);
4095
+ menu.append(label);
4096
+ }
3802
4097
  const button = document.createElement('button');
3803
4098
  button.type = 'button';
3804
4099
  button.dataset.slashCommand = command.id;
@@ -3810,13 +4105,44 @@
3810
4105
  button.dataset.slashSkillScope = command.scope;
3811
4106
  }
3812
4107
  button.dataset.active = index === 0 ? 'true' : 'false';
4108
+ const icon = document.createElement('span');
4109
+ icon.className = 'codex-slash-menu-icon';
4110
+ icon.innerHTML = Modules.PanelIcons.svg(SLASH_MENU_ICONS[command.kind] || 'file');
4111
+ const text = document.createElement('span');
4112
+ text.className = 'codex-slash-menu-text';
3813
4113
  const title = document.createElement('span');
3814
- title.textContent = command.title;
4114
+ title.className = 'codex-slash-menu-title';
4115
+ appendHighlightedText(title, command.title, query);
4116
+ if (command.slug) {
4117
+ const slug = document.createElement('code');
4118
+ appendHighlightedText(slug, command.slug, query);
4119
+ title.append(slug);
4120
+ }
3815
4121
  const subtitle = document.createElement('small');
3816
4122
  subtitle.textContent = command.subtitle;
3817
- button.append(title, subtitle);
4123
+ subtitle.title = command.subtitle;
4124
+ text.append(title, subtitle);
4125
+ button.append(icon, text);
4126
+ if (command.kind === 'compact' && percent !== null) {
4127
+ const meta = document.createElement('span');
4128
+ meta.className = 'codex-slash-menu-meta';
4129
+ meta.dataset.level = Modules.ContextUsage.level(percent);
4130
+ meta.title = tr('contextUsageTitle');
4131
+ meta.textContent = new Intl.NumberFormat(getLocale() === 'zh' ? 'zh-CN' : 'en-US', { maximumFractionDigits: 0 }).format(percent) + '%';
4132
+ button.append(meta);
4133
+ }
3818
4134
  menu.append(button);
3819
4135
  });
4136
+ const foot = document.createElement('div');
4137
+ foot.className = 'codex-slash-menu-foot';
4138
+ foot.dataset.slashMenuFoot = '';
4139
+ for (const [keys, label] of [[['↑', '↓'], 'slashKeyMove'], [['Enter'], 'slashKeyRun'], [['Esc'], 'slashKeyClose']]) {
4140
+ const hint = document.createElement('span');
4141
+ for (const key of keys) hint.append(Object.assign(document.createElement('kbd'), { textContent: key }));
4142
+ hint.append(tr(label));
4143
+ foot.append(hint);
4144
+ }
4145
+ menu.append(foot);
3820
4146
  renderedSlashCommands = new Map(commands.map(command => [command.id, command]));
3821
4147
  menu.dataset.activeIndex = '0';
3822
4148
  menu.hidden = false;
@@ -3891,10 +4217,16 @@
3891
4217
  });
3892
4218
  const active = buttons[index];
3893
4219
  if (!active || !menu.clientHeight) return;
4220
+ // Wrapping to the first item also reveals its group label.
4221
+ if (index === 0) {
4222
+ menu.scrollTop = 0;
4223
+ return;
4224
+ }
3894
4225
  const viewport = menu.getBoundingClientRect();
3895
4226
  const item = active.getBoundingClientRect();
3896
4227
  const top = viewport.top + menu.clientTop;
3897
- const bottom = top + menu.clientHeight;
4228
+ // The sticky key-hint footer covers the bottom of the scroll area.
4229
+ const bottom = top + menu.clientHeight - (menu.querySelector('[data-slash-menu-foot]')?.offsetHeight || 0);
3898
4230
  // Scroll only this popup; scrollIntoView may move the chat or editor too.
3899
4231
  if (item.height > menu.clientHeight || item.top < top) {
3900
4232
  menu.scrollTop += item.top - top;
@@ -3904,6 +4236,7 @@
3904
4236
  }
3905
4237
 
3906
4238
  function selectSlashCommand(commandId) {
4239
+ if (sessionManager.isTransitioning()) return;
3907
4240
  const command = renderedSlashCommands.get(commandId);
3908
4241
  if (!command) {
3909
4242
  closeSlashMenu();
@@ -3916,6 +4249,11 @@
3916
4249
  applyAtFileSelection(command);
3917
4250
  return;
3918
4251
  }
4252
+ if (command.kind === 'compact') {
4253
+ closeSlashMenu();
4254
+ safeRunTask({ operation: 'compact', consumeCompactDraft: true });
4255
+ return;
4256
+ }
3919
4257
  clearSlashTriggerFromTaskInput();
3920
4258
  closeSlashMenu();
3921
4259
  if (command.kind === 'installer') {
@@ -4191,8 +4529,15 @@
4191
4529
  Bridge: Modules.SharedSessionBridge, StorageDb: Modules.StorageDb,
4192
4530
  getScope: () => ({ accountScopeId: cachedAccountScopeId, projectId: getCurrentProjectId(), generation: spaRouteGeneration }),
4193
4531
  getState: () => state, setState: next => { state = next; },
4194
- isBusy: () => Boolean(readLiveRunViewForSaveStateGuard() || document.querySelector('dialog[open]')),
4195
- normalize: normalizePanelState, render: applyStateToPanel,
4532
+ // An in-flight Accept/Undo keeps its view until it settles; refresh applies afterwards.
4533
+ isBusy: () => Boolean(pendingDraftSave || readLiveRunViewForSaveStateGuard() || trackedChangeInFlight.size
4534
+ || runReviewController?.hasActiveReview?.() || document.querySelector('dialog[open]')),
4535
+ hasLocalDraft: current => Boolean(current.task) && composerDraftEditedHere,
4536
+ normalize: normalizePanelState,
4537
+ render: () => {
4538
+ applyStateToPanel();
4539
+ composerDraftEditedHere = false;
4540
+ },
4196
4541
  notice: error => appendStorageNoticeOnce('shared-history-refresh', tx(
4197
4542
  `Could not refresh shared history: ${error.message}`,
4198
4543
  `共享会话历史刷新失败:${error.message}`
@@ -4292,6 +4637,7 @@
4292
4637
  storageKey = getProjectStorageKey(LEGACY_STORAGE_KEY, window.location.href);
4293
4638
  const reloaded = await loadStoredState();
4294
4639
  state = normalizeLoadedPanelState(reloaded);
4640
+ composerDraftEditedHere = false;
4295
4641
  await getRunReviewController().recoverInterruptedUndoStatuses();
4296
4642
  applyStateToPanel();
4297
4643
  } catch (_error) {
@@ -4354,6 +4700,7 @@
4354
4700
  function leaveActiveProject(newId) {
4355
4701
  const prevId = activeProjectId;
4356
4702
  activeProjectId = newId; // null for non-project URLs (spec §5.7.1)
4703
+ if (prevId !== newId) composerDraftEditedHere = false;
4357
4704
  getComposerDraftPersistence().syncScope();
4358
4705
  if (!prevId || prevId === newId) {
4359
4706
  return;
@@ -4380,7 +4727,15 @@
4380
4727
  }
4381
4728
 
4382
4729
  async function onSpaRouteChange() {
4730
+ // Snapshot the departing draft before hydration replaces state. The detached
4731
+ // writer retains its original account/project and never reads the new view's attachments.
4732
+ if (pendingDraftSave?.projectId === activeProjectId
4733
+ && pendingDraftSave?.accountScopeId === cachedAccountScopeId) {
4734
+ void saveState({ draftScope: pendingDraftSave });
4735
+ }
4736
+ resetDraftSave();
4383
4737
  const generation = ++spaRouteGeneration;
4738
+ activeTurnControl.cancelRecovery();
4384
4739
  const url = new URL(window.location.href);
4385
4740
  const pathname = url.pathname;
4386
4741
  const previousProjectId = activeProjectId;
@@ -4604,7 +4959,7 @@
4604
4959
  operations: [],
4605
4960
  status: tr('modeAsk')
4606
4961
  });
4607
- appendCompletionReport({
4962
+ appendCompletionReport({ conclusionKey: !notes ? 'ask_inspected' : undefined,
4608
4963
  conclusion: notes || tx('This run only inspected and explained; no files were written.', '这轮只做了检查和说明,没有写入文件。'),
4609
4964
  status: tr('modeAsk'),
4610
4965
  notes,
@@ -4646,7 +5001,7 @@
4646
5001
  applyResults,
4647
5002
  status: 'applied with delete plan'
4648
5003
  });
4649
- appendCompletionReport({
5004
+ appendCompletionReport({ conclusionKey: !notes ? 'delete_handled' : undefined,
4650
5005
  conclusion: notes || tx('Changes were written, and delete items were handled after your confirmation.', '已写入修改,并按你的确认处理删除项。'),
4651
5006
  status: 'applied with delete plan',
4652
5007
  notes,
@@ -4669,7 +5024,7 @@
4669
5024
  status: 'applied without deletes',
4670
5025
  deletePlanRejected: true
4671
5026
  });
4672
- appendCompletionReport({
5027
+ appendCompletionReport({ conclusionKey: 'delete_rejected',
4673
5028
  conclusion: tx('Non-delete changes were kept; delete items were not written.', '已保留非删除修改;删除项没有写入。'),
4674
5029
  status: 'applied without deletes',
4675
5030
  notes,
@@ -4699,7 +5054,7 @@
4699
5054
  applyResults: [applied],
4700
5055
  status: result.status || 'completed'
4701
5056
  });
4702
- appendCompletionReport({
5057
+ appendCompletionReport({ conclusionKey: !notes ? 'run_complete' : undefined,
4703
5058
  conclusion: notes || tx('This run is complete.', '这轮任务已完成。'),
4704
5059
  status: result.status || 'completed',
4705
5060
  notes,
@@ -4781,10 +5136,19 @@
4781
5136
  // Thread runProjectId so the page-side dispatcher gates the toggle. Same
4782
5137
  // intent as the pre-flight check above: do not flip Track Changes /
4783
5138
  // Editing in a different project than the run is bound to.
5139
+ const owner = currentRunView;
4784
5140
  const result = await callPageBridge(method, {
4785
5141
  waitMs: 1800,
4786
5142
  runProjectId: currentRunView?.runProjectId || ''
4787
5143
  });
5144
+ throwIfRunCancellationRequested();
5145
+ if (owner && (owner !== currentRunView || owner.runProjectId !== getCurrentProjectId()
5146
+ || owner.runAccountScopeId !== cachedAccountScopeId)) throw createRunCancellationError();
5147
+ if (requireReviewing && (result?.ok || result?.code === 'reviewing_unavailable')) {
5148
+ projectSettingsCoordinator.observeReviewingAvailability(result.ok ? 'available' : 'unavailable',
5149
+ result.ok ? 'reviewing_confirmed' : 'upgrade_modal');
5150
+ syncProbeStatus();
5151
+ }
4788
5152
  if (result?.ok) {
4789
5153
  appendRunEvent({
4790
5154
  title: requireReviewing
@@ -4816,6 +5180,7 @@
4816
5180
  code: result?.code || (requireReviewing ? 'reviewing_not_enabled' : 'editing_not_confirmed'),
4817
5181
  reason: result?.reason || (requireReviewing ? 'Reviewing/Track Changes was not enabled' : 'Editing mode was not confirmed'),
4818
5182
  reviewing: result?.reviewing || null,
5183
+ evidence: result?.evidence, failure: result?.failure,
4819
5184
  requireReviewing
4820
5185
  };
4821
5186
  }
@@ -4830,8 +5195,9 @@
4830
5195
  operation,
4831
5196
  result: {
4832
5197
  ok: false,
4833
- code: blockedByEditing ? 'editing_not_confirmed' : 'reviewing_not_enabled',
4834
- reason: blockedByEditing
5198
+ code: reviewing?.code || (blockedByEditing ? 'editing_not_confirmed' : 'reviewing_not_enabled'),
5199
+ evidence: reviewing?.evidence, failure: reviewing?.failure,
5200
+ reason: reviewing?.code === 'reviewing_unavailable' ? tr('failureReason_reviewing_unavailable_user') : blockedByEditing
4835
5201
  ? tx(
4836
5202
  'Track is off, but Overleaf Editing mode was not verified before writing. Codex did not write this file.',
4837
5203
  '已关闭“留痕”,但写入前没有确认 Overleaf 正在用 Editing 模式。Codex 没有写入这个文件。'
@@ -4885,13 +5251,13 @@
4885
5251
 
4886
5252
  async function refreshProbe(options = {}) {
4887
5253
  const projectId = getCurrentProjectId();
5254
+ const accountScopeId = cachedAccountScopeId;
4888
5255
  const userInitiated = options.userInitiated === true;
4889
5256
  if (userInitiated) {
4890
5257
  setRefreshProbeLoading(true);
4891
5258
  const status = panel?.querySelector('[data-probe-status]');
4892
5259
  if (status) {
4893
- status.textContent = tr('refreshProbeLoading');
4894
- status.dataset.ok = 'false';
5260
+ renderProbeStatusMessage(status, tr('refreshProbeLoading'));
4895
5261
  status.dataset.refreshing = 'true';
4896
5262
  }
4897
5263
  }
@@ -4901,13 +5267,14 @@
4901
5267
  manualOverride: state?.requireReviewing === false
4902
5268
  });
4903
5269
  if (projectId !== getCurrentProjectId()) return null;
5270
+ if (accountScopeId !== cachedAccountScopeId) return null;
5271
+ if (probe.capabilities?.reviewing?.confirmed === true) {
5272
+ projectSettingsCoordinator.observeReviewingAvailability('available', 'reviewing_confirmed');
5273
+ }
4904
5274
  probeStatusSnapshot = { projectId, probe, userInitiated };
4905
5275
  const status = panel?.querySelector('[data-probe-status]');
4906
5276
  if (status) {
4907
- status.textContent = userInitiated
4908
- ? tr('refreshProbeDone', { status: formatProbeStatusBar(probe) })
4909
- : formatProbeStatusBar(probe);
4910
- status.dataset.ok = isProbeReadyForCurrentMode(probe) ? 'true' : 'false';
5277
+ renderProbeStatusLine(status, probe, { refreshed: userInitiated });
4911
5278
  status.dataset.refreshing = 'false';
4912
5279
  }
4913
5280
  if (!options.quiet && !userInitiated) {
@@ -4924,8 +5291,7 @@
4924
5291
  probeStatusSnapshot = { projectId, failed: true };
4925
5292
  const status = panel?.querySelector('[data-probe-status]');
4926
5293
  if (status) {
4927
- status.textContent = tr('refreshProbeFailed');
4928
- status.dataset.ok = 'false';
5294
+ renderProbeStatusMessage(status, tr('refreshProbeFailed'), 'warn');
4929
5295
  status.dataset.refreshing = 'false';
4930
5296
  }
4931
5297
  console.warn('[codex-overleaf] refresh probe failed', error);
@@ -4945,23 +5311,81 @@
4945
5311
  button.dataset.loading = loading ? 'true' : 'false';
4946
5312
  button.disabled = Boolean(loading);
4947
5313
  button.setAttribute('aria-busy', loading ? 'true' : 'false');
5314
+ setElementTitleAndAria('[data-refresh]', tr(loading ? 'refreshProbeLoading' : 'refreshProbe'),
5315
+ tr(loading ? 'refreshProbeLoading' : 'refreshProbeTitle'));
4948
5316
  }
4949
5317
 
4950
5318
  function syncProbeStatus(mode = state?.mode) {
4951
5319
  const status = panel?.querySelector('[data-probe-status]');
4952
5320
  if (!status) return;
4953
5321
  if (status.dataset.refreshing === 'true') {
4954
- status.textContent = tr('refreshProbeLoading');
5322
+ renderProbeStatusMessage(status, tr('refreshProbeLoading'));
4955
5323
  return;
4956
5324
  }
4957
5325
  if (probeStatusSnapshot?.projectId !== getCurrentProjectId()) return;
4958
5326
  const { failed, probe, userInitiated } = probeStatusSnapshot;
4959
- const text = failed ? tr('refreshProbeFailed') : formatProbeStatusBar(probe, mode);
4960
- status.textContent = userInitiated && !failed ? tr('refreshProbeDone', { status: text }) : text;
4961
- status.dataset.ok = !failed && isProbeReadyForCurrentMode(probe, mode) ? 'true' : 'false';
5327
+ if (failed) renderProbeStatusMessage(status, tr('refreshProbeFailed'), 'warn');
5328
+ else renderProbeStatusLine(status, probe, { refreshed: userInitiated });
4962
5329
  if (!failed) updateExistingProbeNotice(probe, mode);
4963
5330
  }
4964
5331
 
5332
+ // Keep the read scope visible; a known Track limitation adds its own warning segment.
5333
+ function getProbeScope(probe) {
5334
+ const selection = selectionContextView.capture();
5335
+ const focusFiles = getActiveFocusFiles();
5336
+ // Match submission priority: an edit selection, then explicit focus files,
5337
+ // then reference context, ahead of the editor's implicit current-file hint.
5338
+ if (selection?.mode === 'edit') return { icon: 'file', key: 'probeScopeSelection' };
5339
+ if (focusFiles.some(file => /\.tex$/i.test(file))) return { icon: 'file', key: 'probeScopeFiles' };
5340
+ if (focusFiles.length || selection) return { icon: 'attach', key: 'probeScopeContext' };
5341
+ if (getProbeRunReadiness(probe).editorOk) return { icon: 'file', key: 'probeScopeCurrentFile' };
5342
+ return { icon: 'folder', key: 'probeScopeProject' };
5343
+ }
5344
+
5345
+ function isReviewingKnownUnavailable() {
5346
+ return Modules.Reviewing.knownUnavailable(projectSettingsCoordinator.getReviewingAvailability());
5347
+ }
5348
+
5349
+ function syncReviewingTooltip() {
5350
+ const toggle = panel?.querySelector('[data-require-reviewing]');
5351
+ const title = tr(isReviewingKnownUnavailable() ? 'reviewingUnavailableTooltip' : 'requireReviewingTitle');
5352
+ if (toggle) toggle.title = title;
5353
+ const label = toggle?.closest('.codex-review-toggle');
5354
+ if (label) label.title = title;
5355
+ }
5356
+
5357
+ function renderProbeStatusLine(status, probe, options = {}) {
5358
+ const scope = getProbeScope(probe);
5359
+ const parts = [];
5360
+ if (options.refreshed) parts.push(tr('refreshProbeDone', { status: '' }).trim());
5361
+ const label = document.createElement('span');
5362
+ label.className = 'codex-probe-scope';
5363
+ label.innerHTML = Modules.PanelIcons?.svg?.(scope.icon) || ''; // Static, repository-owned icon path.
5364
+ label.append(tr(scope.key));
5365
+ parts.push(label);
5366
+ if (isExperimentalOtEnabled()) {
5367
+ const separator = document.createElement('span');
5368
+ separator.className = 'codex-probe-sep';
5369
+ separator.textContent = '·';
5370
+ parts.push(separator, 'OT ' + formatOtStatusLabel(getCurrentOtStatus()));
5371
+ }
5372
+ if (state.requireReviewing && isReviewingKnownUnavailable()) {
5373
+ const warning = document.createElement('span');
5374
+ warning.dataset.tone = 'warn';
5375
+ warning.textContent = ' · ' + tr('reviewingUnavailableStatus');
5376
+ parts.push(warning);
5377
+ }
5378
+ syncReviewingTooltip();
5379
+ status.replaceChildren(...parts);
5380
+ delete status.dataset.tone;
5381
+ }
5382
+
5383
+ function renderProbeStatusMessage(status, text, tone = '') {
5384
+ status.replaceChildren(text);
5385
+ if (tone) status.dataset.tone = tone;
5386
+ else delete status.dataset.tone;
5387
+ }
5388
+
4965
5389
  function formatProbeStatusBar(probe, mode = state?.mode) {
4966
5390
  const readiness = getProbeRunReadiness(probe);
4967
5391
  const reviewingOk = readiness.reviewingOk;
@@ -4995,17 +5419,7 @@
4995
5419
  if (!status || status.dataset.refreshing === 'true') {
4996
5420
  return;
4997
5421
  }
4998
- const text = String(status.textContent || '');
4999
- if (!text) {
5000
- return;
5001
- }
5002
- const base = text.split(' · OT ')[0];
5003
- status.textContent = appendOtStatusToProbeStatus(base);
5004
- }
5005
-
5006
- function isProbeReadyForCurrentMode(probe, mode = state?.mode) {
5007
- const readiness = getProbeRunReadiness(probe);
5008
- return mode === 'ask' || readiness.reviewingOk;
5422
+ syncProbeStatus();
5009
5423
  }
5010
5424
 
5011
5425
  function getProbeRunReadiness(probe) {
@@ -5110,8 +5524,8 @@
5110
5524
  ready: true,
5111
5525
  message: manualOverride
5112
5526
  ? tx(
5113
- `Ready: you confirmed Overleaf Track Changes is on. Codex ${readiness.contextLabel}.`,
5114
- `可以运行:你已确认 Overleaf 已开启留痕,Codex ${readiness.contextLabel}。`
5527
+ `Ready: Codex writes without tracked changes and ${readiness.contextLabel}.`,
5528
+ `可以运行:Codex 写入时不使用留痕,并且${readiness.contextLabel}。`
5115
5529
  )
5116
5530
  : tx(
5117
5531
  `Ready: Codex verified Overleaf Reviewing/Track Changes is on and ${readiness.contextLabel}.`,
@@ -5120,21 +5534,13 @@
5120
5534
  };
5121
5535
  }
5122
5536
 
5123
- if (readiness.contextReady) {
5124
- return {
5125
- ready: false,
5126
- message: tx(
5127
- `Not safe to write yet: Codex ${readiness.contextLabel}, but Overleaf Reviewing/Track Changes was not verified. Turn on Reviewing first, or disable Track below.`,
5128
- `还不能安全写入:Codex ${readiness.contextLabel},但没有确认 Overleaf 已开启 Reviewing/Track Changes。请先打开 Reviewing,或关闭下方的安全检查。`
5129
- )
5130
- };
5131
- }
5132
-
5537
+ // ensureReviewingBeforeWrite switches Overleaf to Reviewing before the first
5538
+ // write and blocks the write only if that cannot be confirmed.
5133
5539
  return {
5134
- ready: false,
5540
+ ready: true,
5135
5541
  message: tx(
5136
- 'Not safe to write yet: turn on Overleaf Reviewing/Track Changes first. Codex will read the whole project at run time; you can also use @file to focus on specific files.',
5137
- '还不能安全写入:请先在 Overleaf 打开 Reviewing/Track Changes;Codex 会在运行时读取整个项目,也可以用 @file 指定重点文件。'
5542
+ `Ready: Codex ${readiness.contextLabel} and will switch Overleaf to Reviewing/Track Changes before writing.`,
5543
+ `可以运行:Codex ${readiness.contextLabel},写入前会把 Overleaf 切到 Reviewing/Track Changes。`
5138
5544
  )
5139
5545
  };
5140
5546
  }
@@ -5521,6 +5927,7 @@
5521
5927
  codexLocalSkillEnabled: prefs.codexLocalSkillEnabled || {},
5522
5928
  experimentalOtByProject: prefs.experimentalOtByProject || {},
5523
5929
  customInstructionsByProject: prefs.customInstructionsByProject || {},
5930
+ reviewingAvailabilityByProject: prefs.reviewingAvailabilityByProject || {},
5524
5931
  governanceRulesByProject: normalizeGovernanceRulesByProject(prefs.governanceRulesByProject),
5525
5932
  panelWidth: prefs.panelWidth || PANEL_DEFAULT_WIDTH,
5526
5933
  sessions: sessions.map(session => ({
@@ -5531,6 +5938,7 @@
5531
5938
  focusFiles: session.focusFiles || [], selectionContext: session.selectionContext?.projectId === projectId ? session.selectionContext : null,
5532
5939
  projectReferenceFiles: session.projectReferenceFiles || [],
5533
5940
  codexThreadId: session.codexThreadId || '',
5941
+ contextUsage: session.contextUsage || null,
5534
5942
  forkedFromSessionId: session.forkedFromSessionId || '',
5535
5943
  forkedFromRunId: session.forkedFromRunId || '',
5536
5944
  createdAt: session.createdAt,
@@ -5576,6 +5984,7 @@
5576
5984
  codexLocalSkillEnabled: fallback.codexLocalSkillEnabled || {},
5577
5985
  experimentalOtByProject: fallback.experimentalOtByProject || {},
5578
5986
  customInstructionsByProject: fallback.customInstructionsByProject || {},
5987
+ reviewingAvailabilityByProject: fallback.reviewingAvailabilityByProject || {},
5579
5988
  governanceRulesByProject: normalizeGovernanceRulesByProject(fallback.governanceRulesByProject),
5580
5989
  panelWidth: fallback.panelWidth || PANEL_DEFAULT_WIDTH,
5581
5990
  sessions: [],
@@ -5610,9 +6019,8 @@
5610
6019
  // Settlement owns a run after navigation away from its project. Ordinary
5611
6020
  // view saves must not write that run into the current route's project.
5612
6021
  // An explicit override is reserved for callers that own a detached scope.
5613
- const projectIdOverride = options && typeof options.projectIdOverride === 'string'
5614
- ? options.projectIdOverride
5615
- : '';
6022
+ const projectIdOverride = options?.draftScope?.projectId || (options && typeof options.projectIdOverride === 'string'
6023
+ ? options.projectIdOverride : '');
5616
6024
  const urlProjectId = getCurrentProjectId();
5617
6025
  const projectId = projectIdOverride || urlProjectId;
5618
6026
  if (!projectId) {
@@ -5630,6 +6038,8 @@
5630
6038
  ));
5631
6039
  if (options?.requireDurable || options?.reviewRunIds?.length) throw new Error('Persistence project changed.'); return;
5632
6040
  }
6041
+ const savedDraft = !projectIdOverride && pendingDraftSave?.scope === getComposerDraftScope()
6042
+ ? pendingDraftSave : null;
5633
6043
  const compactState = prepareStateForStorage(state, { onAggressive: notifyAggressiveCompactionOnce });
5634
6044
  compactState.autoRecompile = state.autoRecompile;
5635
6045
  compactState.loadCodexLocalSkills = state.loadCodexLocalSkills !== false;
@@ -5638,10 +6048,17 @@
5638
6048
  compactState.codexLocalSkillEnabled = getCodexLocalSkillEnabled();
5639
6049
  compactState.experimentalOtByProject = state.experimentalOtByProject;
5640
6050
  compactState.customInstructionsByProject = state.customInstructionsByProject;
6051
+ compactState.reviewingAvailabilityByProject = state.reviewingAvailabilityByProject;
5641
6052
  compactState.governanceRulesByProject = state.governanceRulesByProject;
5642
6053
  const stateSnapshot = typeof structuredClone === 'function'
5643
6054
  ? structuredClone(state)
5644
6055
  : JSON.parse(JSON.stringify(state));
6056
+ // Keep consumption provenance on the live run owner across ordinary
6057
+ // session normalization; it is not a composer edit or a queued input.
6058
+ const consumedSession = liveRunView?.draftConsumption && liveRunView.runProjectId === projectId
6059
+ && liveRunView.runAccountScopeId === cachedAccountScopeId
6060
+ ? stateSnapshot.sessions?.find(session => session.id === liveRunView.sessionId) : null;
6061
+ if (consumedSession?.task === '') consumedSession.draftConsumption = { ...liveRunView.draftConsumption };
5645
6062
  const compactStateSnapshot = typeof structuredClone === 'function'
5646
6063
  ? structuredClone(compactState)
5647
6064
  : JSON.parse(JSON.stringify(compactState));
@@ -5662,7 +6079,9 @@
5662
6079
  normalizeCustomInstructionsByProject,
5663
6080
  persistenceContext
5664
6081
  });
5665
- await getComposerDraftPersistence().save({ accountScopeId: persistenceContext.scope.accountScopeId, projectId });
6082
+ if (!options?.draftScope) {
6083
+ await getComposerDraftPersistence().save({ accountScopeId: persistenceContext.scope.accountScopeId, projectId });
6084
+ }
5666
6085
  };
5667
6086
 
5668
6087
  const persistenceCoordinator = getScopedPersistenceCoordinator();
@@ -5671,17 +6090,16 @@
5671
6090
  code: 'scoped_persistence_unavailable'
5672
6091
  });
5673
6092
  } else {
5674
- const committed = await persistenceCoordinator.commit(
5675
- projectId,
5676
- {
6093
+ const commitOptions = {
5677
6094
  detached: Boolean(projectIdOverride),
5678
6095
  rebase: true,
5679
6096
  queueMutation: options?.queueMutation,
5680
6097
  queueMutations: options?.queueMutations,
5681
6098
  sessionTombstones: options?.deletedSessionIds
5682
- },
5683
- persistScopedState
5684
- );
6099
+ };
6100
+ const committed = options?.draftScope
6101
+ ? await persistenceCoordinator.commitDetached(projectId, options.draftScope.accountScopeId, commitOptions, persistScopedState)
6102
+ : await persistenceCoordinator.commit(projectId, commitOptions, persistScopedState);
5685
6103
  if (!committed.ok && (
5686
6104
  options?.requireDurable || options?.reviewRunIds?.length || options?.recoveryRuns?.length || options?.queueMutation
5687
6105
  || options?.queueMutations?.length
@@ -5692,6 +6110,16 @@
5692
6110
  });
5693
6111
  }
5694
6112
  if (committed.ok) {
6113
+ if (savedDraft && pendingDraftSave === savedDraft) {
6114
+ const capturedTask = stateSnapshot.sessions.find(item => item.id === stateSnapshot.activeSessionId)?.task;
6115
+ const storedTask = StorageDb.buildSessionRecord({ task: capturedTask }).task;
6116
+ if (!(written || []).some(session => session.id === stateSnapshot.activeSessionId && session.task === storedTask)) {
6117
+ throw Object.assign(new Error(tx('The latest draft could not be saved. It is still available in this page.',
6118
+ '最新草稿未能保存,内容仍保留在当前页面。')), { code: 'draft_state_not_persisted' });
6119
+ }
6120
+ // A newer input revision keeps its own timer, even if this save finishes later.
6121
+ resetDraftSave();
6122
+ }
5695
6123
  const retentionUpdates = StorageDb.projectRetentionUpdates?.({ liveState: state, capturedState: stateSnapshot, written,
5696
6124
  capturedScope: retentionScope, currentScope: { accountScopeId: typeof cachedAccountScopeId === 'string' ? cachedAccountScopeId : '',
5697
6125
  projectId: getCurrentProjectId(), generation: typeof spaRouteGeneration === 'number' ? spaRouteGeneration : null } }) || [];
@@ -5707,6 +6135,8 @@
5707
6135
  }
5708
6136
  } catch (error) {
5709
6137
  if (options?.requireDurable) throw error;
6138
+ // A departing draft must never fall back using the destination page's state/scope.
6139
+ if (options?.draftScope) return;
5710
6140
  if (error?.code === 'recovery_scope_changed') return;
5711
6141
  if (
5712
6142
  error?.code === 'account_scope_unavailable' &&
@@ -5824,6 +6254,65 @@
5824
6254
  }, delayMs);
5825
6255
  }
5826
6256
 
6257
+ function getComposerDraftScope() {
6258
+ return JSON.stringify([cachedAccountScopeId, getCurrentProjectId(), spaRouteGeneration, state.activeSessionId]);
6259
+ }
6260
+
6261
+ function resetDraftSave() {
6262
+ if (draftSaveTimer) clearTimeout(draftSaveTimer);
6263
+ draftSaveTimer = null;
6264
+ pendingDraftSave = null;
6265
+ }
6266
+
6267
+ function captureComposerDraft() {
6268
+ const input = panel?.querySelector('[data-task]');
6269
+ if (!input || !state.activeSessionId) return;
6270
+ const next = updateActiveSessionDraft(state, input.value);
6271
+ if (next === state) return;
6272
+ if (next.task !== state.task) {
6273
+ composerDraftEditedHere = true;
6274
+ if (currentRunView?.sessionId === state.activeSessionId) delete currentRunView.draftConsumption;
6275
+ }
6276
+ state = next;
6277
+ const now = Date.now(), scope = getComposerDraftScope();
6278
+ pendingDraftSave = {
6279
+ scope,
6280
+ projectId: getCurrentProjectId(),
6281
+ accountScopeId: cachedAccountScopeId,
6282
+ firstAt: pendingDraftSave?.scope === scope ? pendingDraftSave.firstAt : now
6283
+ };
6284
+ if (draftSaveTimer) clearTimeout(draftSaveTimer);
6285
+ const delay = Math.max(0, Math.min(DRAFT_SAVE_DELAY_MS, pendingDraftSave.firstAt + DRAFT_SAVE_MAX_WAIT_MS - now));
6286
+ draftSaveTimer = setTimeout(() => {
6287
+ draftSaveTimer = null;
6288
+ if (pendingDraftSave?.scope !== getComposerDraftScope()) { resetDraftSave(); return; }
6289
+ pendingDraftSave.firstAt = Date.now();
6290
+ // Join the existing writer immediately; never reset its timer on every key.
6291
+ saveStateSoon(0);
6292
+ }, delay);
6293
+ }
6294
+
6295
+ async function flushComposerDraft({ requireDurable = false } = {}) {
6296
+ if (!pendingDraftSave) return;
6297
+ const scope = getComposerDraftScope();
6298
+ if (pendingDraftSave.scope !== scope) { resetDraftSave(); return; }
6299
+ if (draftSaveTimer) clearTimeout(draftSaveTimer);
6300
+ draftSaveTimer = null;
6301
+ pendingDraftSave.firstAt = Date.now();
6302
+ await flushQueuedSaveState(requireDurable ? { requireDurable: true } : null);
6303
+ if (requireDurable && scope !== getComposerDraftScope()) {
6304
+ throw new Error(tx('The active session changed while saving the draft. Try again.',
6305
+ '保存草稿时活动会话已改变,请重试。'));
6306
+ }
6307
+ }
6308
+
6309
+ function flushComposerDraftOnLeave() {
6310
+ // Lifecycle events cannot await completion; periodic saves remain the primary protection.
6311
+ flushComposerDraft().catch(error => appendStorageNoticeOnce('save-failed', tx(
6312
+ `Failed to save session state: ${localizeVisibleReason(error.message)}`,
6313
+ `保存会话状态失败:${localizeVisibleReason(error.message)}`)));
6314
+ }
6315
+
5827
6316
  function runQueuedSaveState() {
5828
6317
  if (saveStateInFlightPromise) {
5829
6318
  saveStateRunAfterFlight = true;
@@ -5924,13 +6413,11 @@
5924
6413
  async function persistPanelInputs(event) {
5925
6414
  const globalWrite = getGlobalPreferences().handleInput(event);
5926
6415
  if (globalWrite) return globalWrite;
5927
- // Typing in the task box is the hottest write path: a full saveState per
5928
- // keystroke serializes every session/run/event and rewrites IndexedDB, so
5929
- // panels get slower as history grows. Drafts take the debounced saver;
5930
- // committed control changes keep the immediate full save below.
5931
- if (event?.type === 'input' && event?.target?.matches?.('[data-task]')) {
5932
- readPanelInputs();
5933
- saveStateSoon();
6416
+ // Draft edits retain run/event identity. Composition updates also capture the
6417
+ // current text; compositionend captures its committed value if no final input fires.
6418
+ if (event?.target?.matches?.('[data-task]')) {
6419
+ captureComposerDraft();
6420
+ if (event.type === 'change') await flushComposerDraft();
5934
6421
  return;
5935
6422
  }
5936
6423
  if (event?.target?.matches?.('[data-speed]')) {
@@ -5942,8 +6429,18 @@
5942
6429
  }
5943
6430
  renderSpeedOptions(getRenderedModelEntries());
5944
6431
  readPanelInputs();
6432
+ // Only a user re-enabling Track requests another capability check. The
6433
+ // preceding input event and programmatic session renders must not reset it.
6434
+ if (event?.type === 'change' && event.isTrusted === true
6435
+ && event.target?.matches?.('[data-require-reviewing]') && event.target.checked
6436
+ && isReviewingKnownUnavailable()) {
6437
+ projectSettingsCoordinator.observeReviewingAvailability('unknown', 'user_retry');
6438
+ syncReviewingTooltip();
6439
+ syncProbeStatus();
6440
+ }
5945
6441
  renderModelConfigChoices();
5946
6442
  updateModelDisplay();
6443
+ renderContextUsage();
5947
6444
  // Save feedback is per-card now (settingsPanel flashSaved); the global
5948
6445
  // header chip and its Saving/Saved lifecycle are gone.
5949
6446
  await saveState();
@@ -6073,6 +6570,7 @@
6073
6570
  if (findSessionById(submission.sessionId)?.task !== submission.task) return false;
6074
6571
  if (state.activeSessionId !== submission.sessionId) {
6075
6572
  updateSessionById(submission.sessionId, { task: '' });
6573
+ recordSubmittedDraftConsumption(submission.sessionId, submission.task);
6076
6574
  saveStateSoon();
6077
6575
  return false;
6078
6576
  }
@@ -6086,6 +6584,7 @@
6086
6584
  if (taskInput) {
6087
6585
  taskInput.value = '';
6088
6586
  }
6587
+ composerDraftEditedHere = false;
6089
6588
  autosizeTaskTextarea();
6090
6589
  if (!keepAttachments) {
6091
6590
  composerAttachmentController.clear();
@@ -6094,10 +6593,20 @@
6094
6593
  renderComposerSkillInvocation();
6095
6594
  selectionContextView.clear();
6096
6595
  state = updateActiveSession(state, { task: '' });
6596
+ if (submission) recordSubmittedDraftConsumption(submission.sessionId, submission.task);
6597
+ else if (currentRunView?.sessionId === state.activeSessionId) delete currentRunView.draftConsumption;
6097
6598
  saveStateSoon();
6098
6599
  return true;
6099
6600
  }
6100
6601
 
6602
+ function recordSubmittedDraftConsumption(sessionId, task) {
6603
+ if (task && currentRunView?.sessionId === sessionId
6604
+ && currentRunView.runProjectId === getCurrentProjectId()
6605
+ && currentRunView.runAccountScopeId === cachedAccountScopeId) {
6606
+ currentRunView.draftConsumption = { runId: currentRunView.recordId, task };
6607
+ }
6608
+ }
6609
+
6101
6610
  function restoreRejectedLocalSkillSubmission(submission, checkpoint) {
6102
6611
  const input = panel?.querySelector('[data-task]');
6103
6612
  if (!checkpoint || !input || submission.accountScopeId !== cachedAccountScopeId
@@ -6115,6 +6624,7 @@
6115
6624
  state = updateActiveSession(state, { task: submission.task, selectionContext: submission.selectionContext });
6116
6625
  renderComposerSkillInvocation();
6117
6626
  selectionContextView.render();
6627
+ syncProbeStatus();
6118
6628
  autosizeTaskTextarea();
6119
6629
  syncComposerSendAvailability();
6120
6630
  saveStateSoon();
@@ -6122,6 +6632,8 @@
6122
6632
  }
6123
6633
 
6124
6634
  function applyStateToPanel(options = {}) {
6635
+ if (pendingDraftSave && pendingDraftSave.scope !== getComposerDraftScope()) resetDraftSave();
6636
+ contextUsageView?.close();
6125
6637
  selectionContextView.render();
6126
6638
  writingStyleSettings.sync();
6127
6639
  applyPanelTheme(getThemePreference());
@@ -6144,6 +6656,7 @@
6144
6656
  autosizeTaskTextarea();
6145
6657
  syncComposerSendAvailability();
6146
6658
  panel.querySelector('[data-require-reviewing]').checked = state.requireReviewing;
6659
+ syncReviewingTooltip();
6147
6660
  const recompileCheckbox = panel?.querySelector('[data-auto-recompile]');
6148
6661
  if (recompileCheckbox) {
6149
6662
  recompileCheckbox.checked = state.autoRecompile !== false;
@@ -6198,6 +6711,47 @@
6198
6711
  if (record) configureWritebackButton(root, record, { running });
6199
6712
  });
6200
6713
  renderPendingInputs();
6714
+ renderContextUsage();
6715
+ }
6716
+
6717
+ function renderContextUsage() {
6718
+ if (!contextUsageView || !state) return;
6719
+ contextUsageView.setState(getContextUsageDisplay());
6720
+ }
6721
+
6722
+ function getContextUsageDisplay() {
6723
+ const session = getActiveSession(state);
6724
+ const run = currentRunView?.sessionId === session?.id
6725
+ ? findRunRecord(currentRunView.recordId, currentRunView.sessionId) : null;
6726
+ const selection = providerSettingsCoordinator.getRunSelection(state.providerId);
6727
+ const runningSelection = run?.status === 'running' && run.model === state.model
6728
+ && (run.providerId || 'builtin') === (state.providerId || 'builtin');
6729
+ const scope = {
6730
+ threadId: session?.codexThreadId || '',
6731
+ providerId: state.providerId || 'builtin',
6732
+ providerRevision: runningSelection ? Number(run.providerRevision || 0)
6733
+ : selection?.providerRevision ?? ((state.providerId || 'builtin') === 'builtin' ? 0 : null),
6734
+ model: state.model || ''
6735
+ };
6736
+ // The composer can select the next turn's model while this turn runs.
6737
+ // Only show statistics produced by the selected model and provider.
6738
+ const matchesRun = run && run.model === scope.model
6739
+ && (run.providerId || 'builtin') === scope.providerId
6740
+ && Number(run.providerRevision || 0) === scope.providerRevision;
6741
+ // Starting another turn must not revive statistics from before an automatic
6742
+ // compaction. Retain the watermark across this thread's saved normal runs.
6743
+ const compactedAt = (session?.runs || []).reduce((latest, candidate) => candidate && !candidate.forkSnapshot
6744
+ && candidate.operation !== 'compact' && scope.threadId && candidate.codexThreadId === scope.threadId
6745
+ ? Math.max(latest, Modules.RunActivityModel.getCompactedAt(candidate)) : latest, 0);
6746
+ const { usage, pending, compacted } = Modules.ContextUsage.status(session, { ...scope, compactedAt });
6747
+ const compacting = Boolean(matchesRun && Modules.RunActivityModel.project(run).compacting);
6748
+ return { usage, pending, compacted, compacting, compactingKind: compacting ? (run.operation === 'compact' ? 'manual' : 'auto') : '' };
6749
+ }
6750
+
6751
+ function getCompactAvailability() {
6752
+ if (sessionManager.isTransitioning() || updateIdleClient?.isApplying() || currentRunView || runSubmissionPending) return { ok: false, reason: 'compactBusy' };
6753
+ if (!getActiveSession(state)?.codexThreadId) return { ok: false, reason: 'compactNoThread' };
6754
+ return { ok: true };
6201
6755
  }
6202
6756
 
6203
6757
  function prepareRetryReplacement(run, task) {
@@ -6231,6 +6785,7 @@
6231
6785
  sessionId = state.activeSessionId,
6232
6786
  sessionContext = runController.captureSessionContext(state),
6233
6787
  codexThreadId = '',
6788
+ operation,
6234
6789
  task,
6235
6790
  mode,
6236
6791
  model,
@@ -6247,6 +6802,7 @@
6247
6802
  resetAutoFollow();
6248
6803
  const record = {
6249
6804
  id: createRunId(),
6805
+ ...(operation === 'compact' ? { operation } : {}),
6250
6806
  task,
6251
6807
  mode,
6252
6808
  ...providerSettingsCoordinator.getProviderSnapshot(executionSnapshot?.providerId || state?.providerId),
@@ -6276,12 +6832,20 @@
6276
6832
  runAccountScopeId: cachedAccountScopeId || ''
6277
6833
  };
6278
6834
  const active = findSessionById(sessionId);
6279
- const titlePatch = active?.titleSource !== 'manual'
6835
+ const titlePatch = operation !== 'compact' && active?.titleSource !== 'manual'
6280
6836
  ? {
6281
6837
  title: deriveSessionTitle([], task),
6282
6838
  titleSource: 'auto'
6283
6839
  }
6284
6840
  : {};
6841
+ // Establish the in-memory owner before publishing or rendering the record.
6842
+ // If presentation throws, runTask still has the identity needed to settle it.
6843
+ const view = currentRunView = {
6844
+ operation, sessionId, sessionContext, codexThreadId, recordId: record.id,
6845
+ runProjectId: record.runProjectId, runAccountScopeId: record.runAccountScopeId,
6846
+ executionSnapshot: record.executionSnapshot, startedAt: Date.now(), queueItemId,
6847
+ nativeRequestId: '', activeTurn: null
6848
+ };
6285
6849
  updateSessionById(sessionId, {
6286
6850
  runs: runController.replaceRunForRetry(active?.runs, replaceRunId, record),
6287
6851
  ...titlePatch
@@ -6290,6 +6854,16 @@
6290
6854
 
6291
6855
  const log = panel.querySelector('[data-log]');
6292
6856
  const root = renderRunCard(record);
6857
+ Object.assign(view, {
6858
+ root,
6859
+ runProcess: root.querySelector('[data-run-process]'),
6860
+ processLabel: root.querySelector('[data-run-process-summary]'),
6861
+ events: root.querySelector('[data-run-events]'),
6862
+ report: root.querySelector('[data-run-report]'),
6863
+ status: root.querySelector('[data-run-status]'),
6864
+ // Seed references before warm-mirror resolution produces its snapshot.
6865
+ projectFiles: getCurrentProjectReferenceFiles()
6866
+ });
6293
6867
  if (state.activeSessionId === sessionId) {
6294
6868
  removeEmptyRunsMessage(log);
6295
6869
  if (replaceRunId) log.querySelector(`[data-run-id="${cssEscape(replaceRunId)}"]`)?.remove();
@@ -6300,32 +6874,7 @@
6300
6874
  renderSessionList();
6301
6875
  applySessionLabel();
6302
6876
 
6303
- return {
6304
- sessionId,
6305
- sessionContext,
6306
- codexThreadId,
6307
- recordId: record.id,
6308
- // Mirror the immutable runProjectId on the view so writeback /
6309
- // accept / undo dispatches don't need to re-look up the record.
6310
- runProjectId: record.runProjectId,
6311
- runAccountScopeId: record.runAccountScopeId,
6312
- executionSnapshot: record.executionSnapshot,
6313
- root,
6314
- runProcess: root.querySelector('[data-run-process]'),
6315
- processLabel: root.querySelector('[data-run-process-summary]'),
6316
- events: root.querySelector('[data-run-events]'),
6317
- report: root.querySelector('[data-run-report]'),
6318
- status: root.querySelector('[data-run-status]'),
6319
- // A run starts before warm-mirror resolution has produced its lightweight
6320
- // project snapshot. Seed references from the already-prefetched project
6321
- // inventory so early stream events and fast Ask responses can still
6322
- // resolve project-relative locations.
6323
- projectFiles: getCurrentProjectReferenceFiles(),
6324
- startedAt: Date.now(),
6325
- queueItemId,
6326
- nativeRequestId: '',
6327
- activeTurn: null
6328
- };
6877
+ return view;
6329
6878
  }
6330
6879
 
6331
6880
  function touchSessionForTerminalRun(sessionId, finishedAt) {
@@ -6388,6 +6937,7 @@
6388
6937
  ].filter(Boolean).join(' · ');
6389
6938
  collapseRunProcess(visibleView, statusText);
6390
6939
  }
6940
+ renderContextUsage();
6391
6941
  }
6392
6942
 
6393
6943
 
@@ -6402,6 +6952,11 @@
6402
6952
  }
6403
6953
 
6404
6954
  const owner = currentRunView && findRunRecord(currentRunView.recordId, currentRunView.sessionId);
6955
+ if (storeContextUsage(event, owner, findSessionById(currentRunView?.sessionId), journalSeq)) {
6956
+ scheduleRunStateSave('stream');
6957
+ renderContextUsage();
6958
+ return;
6959
+ }
6405
6960
  if (owner && Modules.SubagentActivityModel.ingest(owner, event, {
6406
6961
  locale: getLocale(), sanitize: sanitizeAssistantVisibleText, nativeEventSeq: journalSeq
6407
6962
  })) {
@@ -6430,6 +6985,20 @@
6430
6985
  replaceText: activity.replaceText,
6431
6986
  nativeEventSeq: journalSeq
6432
6987
  });
6988
+ if (activity.kind !== 'stream') renderContextUsage();
6989
+ }
6990
+
6991
+ function storeContextUsage(event, record, session, sequence) {
6992
+ if (!record) return false;
6993
+ const usage = Modules.ContextUsage.fromEvent(event, {
6994
+ ...record, providerRevision: Number(record.providerRevision || 0), eventSeq: sequence
6995
+ });
6996
+ if (!usage || !Modules.ContextUsage.isNewer(usage, record.contextUsage)) return false;
6997
+ record.contextUsage = usage;
6998
+ record.nativeEventSeq = Math.max(Number(record.nativeEventSeq || 0), Number(sequence || 0));
6999
+ if (session?.codexThreadId === usage.threadId
7000
+ && Modules.ContextUsage.isNewer(usage, session.contextUsage)) session.contextUsage = usage;
7001
+ return true;
6433
7002
  }
6434
7003
 
6435
7004
  function bindActiveTurn(detail = {}, journalSeq = 0) {
@@ -6438,16 +7007,24 @@
6438
7007
  }
6439
7008
  const threadId = String(detail.threadId || '');
6440
7009
  const turnId = String(detail.turnId || '');
6441
- currentRunView.activeTurn = { threadId, turnId };
7010
+ currentRunView.activeTurn = currentRunView.operation === 'compact' ? null : { threadId, turnId };
6442
7011
  const record = findRunRecord(currentRunView.recordId, currentRunView.sessionId);
6443
7012
  if (record) {
6444
7013
  record.codexThreadId = threadId; record.codexTurnId = turnId;
7014
+ if (record.operation === 'compact') {
7015
+ const session = findSessionById(currentRunView.sessionId);
7016
+ if (session?.contextUsage?.threadId === threadId
7017
+ && Date.parse(session.contextUsage.observedAt) < Date.parse(record.startedAt)) {
7018
+ session.contextUsage = null;
7019
+ }
7020
+ }
6445
7021
  record.nativeEventSeq = Math.max(Number(record.nativeEventSeq || 0), Number(journalSeq || 0));
6446
7022
  }
6447
7023
  if (threadId) {
6448
7024
  updateSessionById(currentRunView.sessionId, { codexThreadId: threadId });
6449
7025
  }
6450
7026
  renderPendingInputs();
7027
+ renderContextUsage();
6451
7028
  saveState().catch(() => {});
6452
7029
  }
6453
7030
 
@@ -6468,7 +7045,7 @@
6468
7045
  function appendRunEvent(input = {}) {
6469
7046
  const { title, status = 'info', detail, timestamp } = input;
6470
7047
  const safeTitle = sanitizeAssistantVisibleText(title) || 'Event';
6471
- if (!currentRunView?.events) {
7048
+ if (!currentRunView) {
6472
7049
  appendPlainLog(safeTitle);
6473
7050
  return;
6474
7051
  }
@@ -6512,21 +7089,20 @@
6512
7089
  })).filter(item => item.path)
6513
7090
  : undefined
6514
7091
  };
7092
+ // Keep the activity timestamp current without retaining a run reference
7093
+ // across session normalization, which replaces run and event objects.
7094
+ const nowMs = Date.now();
7095
+ if (nowMs - lastRunActivityBumpMs > 60000
7096
+ && findRunRecord(currentRunView.recordId, currentRunView.sessionId)) {
7097
+ lastRunActivityBumpMs = nowMs;
7098
+ state = updateActiveSession(state, {});
7099
+ }
6515
7100
  const record = findRunRecord(currentRunView.recordId, currentRunView.sessionId);
6516
7101
  let renderedEvent = event;
6517
7102
  if (record) {
6518
7103
  if (Number.isFinite(Number(input.nativeEventSeq))) {
6519
7104
  record.nativeEventSeq = Math.max(Number(record.nativeEventSeq || 0), Number(input.nativeEventSeq || 0));
6520
7105
  }
6521
- // v1.8.1: keep the session's activity timestamp honest DURING a run —
6522
- // it was pinned to run start, so the dashboard's 30-minute zombie
6523
- // heuristic mislabeled any long live run as interrupted (fleet P1).
6524
- // Throttled to once a minute; updateActiveSession stamps updatedAt.
6525
- const nowMs = Date.now();
6526
- if (nowMs - lastRunActivityBumpMs > 60000) {
6527
- lastRunActivityBumpMs = nowMs;
6528
- state = updateActiveSession(state, {});
6529
- }
6530
7106
  renderedEvent = event.kind === 'stream'
6531
7107
  ? upsertRunStreamRecordEvent(record, event)
6532
7108
  : event;
@@ -6922,7 +7498,7 @@
6922
7498
  }
6923
7499
  await Modules.ScopedPersistenceCoordinator.applyReviewTransition({
6924
7500
  getState: () => state, runId, transition: WritebackSettlement.applySettlementTransition(run, settlement),
6925
- persist: flushQueuedSaveState, assertCurrent: options.assertCurrent
7501
+ persist: flushQueuedSaveState, assertCurrent: options.assertCurrent, SessionState: Modules.SessionState
6926
7502
  });
6927
7503
  }
6928
7504
 
@@ -6939,6 +7515,7 @@
6939
7515
  if (!run) {
6940
7516
  return 0;
6941
7517
  }
7518
+ run = Modules.UndoOperations.withAvailableUndo(run);
6942
7519
  const trackedEditorUndoCount = hasTrackedEditorUndo(run) ? 1 : 0;
6943
7520
  if (trackedEditorUndoCount) {
6944
7521
  return trackedEditorUndoCount + Modules.UndoOperations.getFileUndoOperations(run).length;
@@ -6990,16 +7567,20 @@
6990
7567
  assertCurrent();
6991
7568
  }
6992
7569
 
6993
- async function settleWritebackRecovery(applied, project, verification, target) {
7570
+ async function settleWritebackRecovery(applied, project, verification, target, requireReviewing) {
6994
7571
  const record = findRunRecord(target.recordId, target.sessionId);
6995
7572
  if (!record) return;
7573
+ // retryRequireReviewing is in-memory only; a renormalized run copy loses it.
7574
+ const retryReviewing = typeof requireReviewing === 'boolean' ? requireReviewing
7575
+ : typeof record.retryRequireReviewing === 'boolean' ? record.retryRequireReviewing
7576
+ : record.executionSnapshot?.requireReviewing === true;
6996
7577
  const owner = recoveryOwner(target);
6997
7578
  const skippedPaths = new Set((applied.skipped || []).map(entry => entry.operation?.path));
6998
7579
  const creates = (record.retryWriteback?.operations || []).filter(op => skippedPaths.has(op.path));
6999
7580
  // Untouched edits (never opened / rejected) and deletes whose server check failed are safe to replay.
7000
7581
  const edits = (applied.skipped || []).filter(({ operation: op, result }) => result?.failure?.changedDocument === false && ((op?.type === 'edit'
7001
7582
  && (result.failure.stage === 'navigation' || result.failure.code === 'write_operation_failed')) || (op?.type === 'delete' && result.failure.code === 'source_zip_unavailable'))).map(entry => entry.operation);
7002
- const base = { ...(record.retryWriteback || { ...owner, id: crypto.randomUUID(), requireReviewing: record.retryRequireReviewing === true }),
7583
+ const base = { ...(record.retryWriteback || { ...owner, id: crypto.randomUUID() }), requireReviewing: retryReviewing,
7003
7584
  baseFiles: project?.files || [], requestId: applied.receiptUnconfirmed ? applied.requestId || '' : '' };
7004
7585
  // An oversized edit pre-image must not cost the create retries (the intent has a 300 KiB ceiling).
7005
7586
  const safeRetry = [[...creates, ...edits], creates].filter(ops => ops.length).map(ops => Modules.SessionState
@@ -7039,26 +7620,35 @@
7039
7620
  let finished = false;
7040
7621
  runCancellationRequested = false;
7041
7622
  runCancellationController = new AbortController();
7042
- record.status = 'running';
7043
- record.retryingWriteback = true;
7044
- renderRunHistory({ preserveScroll: true });
7045
- const root = panel.querySelector(`[data-run-id="${cssEscape(runId)}"]`);
7046
- currentRunView = { root, recordId: runId, sessionId: session.id, runProjectId: owner.projectId,
7623
+ const generation = spaRouteGeneration;
7624
+ const requestId = record.nativeRequestId || `writeback:${owner.sessionId}:${runId}`;
7625
+ const ownerToken = crypto.randomUUID();
7626
+ const retryView = { recordId: runId, sessionId: session.id, runProjectId: owner.projectId,
7047
7627
  runAccountScopeId: owner.accountScopeId, executionSnapshot: record.executionSnapshot,
7048
- startedAt: Date.now(), writebackRetry: true, events: root?.querySelector('[data-run-events]'),
7049
- report: root?.querySelector('[data-run-report]'), status: root?.querySelector('[data-run-status]'),
7050
- runProcess: root?.querySelector('[data-run-process]'), processLabel: root?.querySelector('[data-run-process-summary]') };
7628
+ startedAt: Date.now(), writebackRetry: true };
7629
+ currentRunView = retryView;
7051
7630
  setRunning(true);
7052
7631
  const assertOwner = () => {
7053
- if (runCancellationRequested || currentRunView?.recordId !== runId) {
7632
+ if (runCancellationRequested || currentRunView !== retryView) {
7054
7633
  throw Object.assign(new Error('Save checking was cancelled.'), { code: 'codex_cancelled' });
7055
7634
  }
7056
- if (owner.projectId !== getCurrentProjectId() || owner.accountScopeId !== cachedAccountScopeId) {
7635
+ if (generation !== spaRouteGeneration || owner.projectId !== getCurrentProjectId() || owner.accountScopeId !== cachedAccountScopeId) {
7057
7636
  throw Object.assign(new Error('The project or account changed.'), { code: 'aborted_project_changed' });
7058
7637
  }
7059
7638
  };
7060
7639
  try {
7640
+ const bound = await activeTurnControl.bindBrowserRun({ requestId, projectKey: owner.projectId,
7641
+ clientRunId: runId, sessionId: session.id, ownerToken });
7061
7642
  assertOwner();
7643
+ if (!bound) throw new Error(tx('Could not start sync. Reload the page and try again.', '无法开始同步,请刷新页面后重试。'));
7644
+ // Publish running only after another page can observe this browser owner.
7645
+ live().status = 'running';
7646
+ live().retryingWriteback = true;
7647
+ renderRunHistory({ preserveScroll: true });
7648
+ const root = panel.querySelector(`[data-run-id="${cssEscape(runId)}"]`);
7649
+ Object.assign(retryView, { root, events: root?.querySelector('[data-run-events]'),
7650
+ report: root?.querySelector('[data-run-report]'), status: root?.querySelector('[data-run-status]'),
7651
+ runProcess: root?.querySelector('[data-run-process]'), processLabel: root?.querySelector('[data-run-process-summary]') });
7062
7652
  if (intent?.requestId) {
7063
7653
  const receipt = await callPageBridge('getWritebackReceipt', { requestId: intent.requestId, runProjectId: owner.projectId });
7064
7654
  if (receipt?.state === 'running') throw new Error(tx('The previous upload is still finishing. Try again shortly.', '上一轮上传仍在收尾,请稍后再试。'));
@@ -7067,9 +7657,11 @@
7067
7657
  // Edits replay against the run's original base files, so the stale guard still refuses changed documents.
7068
7658
  const result = await applySyncChangesToOverleaf(intent.operations.map(op => op.type === 'edit'
7069
7659
  ? { type: 'write', path: op.path, patches: op.patches, content: op.replaceAll } : op.type === 'delete' ? { type: 'delete', path: op.path } : { ...op, type: 'create' }),
7070
- { id: owner.projectId, files: intent.baseFiles }, { mode: 'auto', requireReviewing: intent.requireReviewing,
7660
+ // The submitted snapshot owns the write mode; older intents may carry a lost value.
7661
+ { id: owner.projectId, files: intent.baseFiles }, { mode: 'auto', requireReviewing: typeof record.executionSnapshot?.requireReviewing === 'boolean'
7662
+ ? record.executionSnapshot.requireReviewing : intent.requireReviewing,
7071
7663
  autoRecompile: retryAutoRecompile,
7072
- retryCreates: true, assistantMessage: tx('Retried the remaining file transfers for this task.', '已重试本轮未完成的文件同步。') });
7664
+ retryCreates: true, assistantMessageKey: 'retry_sync', assistantMessage: tx('Retried the remaining file transfers for this task.', '已重试本轮未完成的文件同步。') });
7073
7665
  assertOwner();
7074
7666
  if (result.settlement?.facts) live().settlement = WritebackSettlement.mergeRetrySettlement(live().settlement, result.settlement.facts); // Keep files outside the retry.
7075
7667
  await finishRunView(result.hasSkippedOperations ? tx('Sync incomplete', '同步未完成') : tx('Synced', '已同步'),
@@ -7094,11 +7686,19 @@
7094
7686
  } finally {
7095
7687
  // Only finishRunView settles a new terminal status; every other exit
7096
7688
  // returns the card to the status it had before the retry.
7689
+ if (currentRunView !== retryView) {
7690
+ activeTurnControl.release(requestId, ownerToken);
7691
+ return;
7692
+ }
7097
7693
  for (const copy of new Set([record, live()])) {
7098
7694
  delete copy.retryingWriteback;
7099
7695
  if (!finished && copy.status === 'running') copy.status = originalStatus;
7100
7696
  }
7101
- if (currentRunView?.recordId === runId) currentRunView = null;
7697
+ saveStateSoon();
7698
+ await flushQueuedSaveState().catch(() => {});
7699
+ activeTurnControl.release(requestId, ownerToken);
7700
+ if (currentRunView !== retryView) return;
7701
+ currentRunView = null;
7102
7702
  runCancellationRequested = false;
7103
7703
  runCancellationController = null;
7104
7704
  setRunning(false);
@@ -7206,11 +7806,18 @@
7206
7806
  return;
7207
7807
  }
7208
7808
 
7209
- const combinedAppliedOperations = [
7809
+ let combinedAppliedOperations = [
7210
7810
  ...(Array.isArray(record.appliedOperations) ? record.appliedOperations : []),
7211
7811
  ...appliedOperations
7212
7812
  ];
7813
+ const checkpoint = buildUndoCheckpoint(project, combinedAppliedOperations);
7814
+ combinedAppliedOperations = checkpoint.appliedOperations;
7213
7815
  record.appliedOperations = combinedAppliedOperations;
7816
+ if (checkpoint.undoUnavailable.length) {
7817
+ appendRunEvent({ title: tr('failureReason_snapshot_undo_unavailable_user'), status: 'warning',
7818
+ detail: checkpoint.undoUnavailable.map(item => ({ [tr('detailFile')]: item.path,
7819
+ [tr('detailReason')]: item.code, [tr('detailNext')]: tr('failureReason_snapshot_undo_unavailable_next') })) });
7820
+ }
7214
7821
  const createdCheckpoint = buildCreatedFileUndoCheckpoint(project, combinedAppliedOperations, record);
7215
7822
 
7216
7823
  if ((currentRunView?.executionSnapshot?.requireReviewing ?? state.requireReviewing) === true) {
@@ -7273,7 +7880,6 @@
7273
7880
  return;
7274
7881
  }
7275
7882
 
7276
- const checkpoint = buildUndoCheckpoint(project, combinedAppliedOperations);
7277
7883
  const createdPaths = new Set(createdCheckpoint.undoOperations.map(operation => operation.path));
7278
7884
  checkpoint.undoOperations = [
7279
7885
  ...checkpoint.undoOperations.filter(operation => !createdPaths.has(operation.path)),
@@ -7283,10 +7889,7 @@
7283
7889
  ...checkpoint.undoBaseFiles.filter(file => !createdPaths.has(file.path)),
7284
7890
  ...createdCheckpoint.undoBaseFiles
7285
7891
  ];
7286
- if (!checkpoint.undoOperations.length) {
7287
- return;
7288
- }
7289
-
7892
+ if (!checkpoint.undoOperations.length && !checkpoint.undoUnavailable.length) return;
7290
7893
  record.undoOperations = checkpoint.undoOperations;
7291
7894
  record.undoBaseFiles = checkpoint.undoBaseFiles;
7292
7895
  record.undoTrackedChanges = [];
@@ -7299,6 +7902,7 @@
7299
7902
  record.undoStatus = '';
7300
7903
  record.partialWriteback = skippedEntries.length > 0;
7301
7904
  refreshRunCardControls(record.id);
7905
+ if (!checkpoint.undoOperations.length) return;
7302
7906
  appendRunEvent({
7303
7907
  title: tr('undoCheckpointPlain', { count: record.undoOperations.length }),
7304
7908
  status: 'completed',
@@ -7361,6 +7965,7 @@
7361
7965
  guidanceId: sanitizeAssistantVisibleText(event.guidanceId)
7362
7966
  };
7363
7967
  if (event.failure) normalized.failure = sanitizeAssistantVisibleValue(event.failure);
7968
+ if (event.detailStructured) normalized.detailStructured = sanitizeAssistantVisibleValue(event.detailStructured);
7364
7969
  record.events = [...(record.events || []), normalized].slice(-MAX_RUN_EVENTS);
7365
7970
  saveStateSoon();
7366
7971
 
@@ -7810,8 +8415,14 @@
7810
8415
  return;
7811
8416
  }
7812
8417
 
8418
+ const groupKey = String(options.groupKey || '');
8419
+ const grouped = groupKey && Array.from(region.children).find(item => item.dataset.groupKey === groupKey);
8420
+ if (grouped) {
8421
+ updateToastGroup(grouped);
8422
+ return;
8423
+ }
7813
8424
  const last = region.lastElementChild;
7814
- if (last?.classList?.contains('codex-toast') && last.dataset.baseText === value) {
8425
+ if (!groupKey && !last?.dataset.groupKey && last?.classList?.contains('codex-toast') && last.dataset.baseText === value) {
7815
8426
  const repeatCount = Number(last.dataset.repeatCount || '1') + 1;
7816
8427
  last.dataset.repeatCount = String(repeatCount);
7817
8428
  const textNode = last.querySelector('[data-toast-text]');
@@ -7829,15 +8440,34 @@
7829
8440
 
7830
8441
  const body = document.createElement('div');
7831
8442
  body.className = 'codex-toast-body';
7832
- body.dataset.toastText = 'true';
7833
- body.textContent = value;
8443
+ if (options.title) {
8444
+ const title = document.createElement('div');
8445
+ title.className = 'codex-toast-title';
8446
+ title.textContent = options.title;
8447
+ body.append(title);
8448
+ }
8449
+ const textNode = document.createElement('div');
8450
+ textNode.dataset.toastText = 'true';
8451
+ textNode.textContent = value;
8452
+ body.append(textNode);
8453
+ if (groupKey) {
8454
+ item.dataset.groupKey = groupKey;
8455
+ const details = document.createElement('details');
8456
+ details.className = 'codex-toast-group';
8457
+ const summary = document.createElement('summary');
8458
+ const list = document.createElement('ul');
8459
+ list.dataset.toastGroupItems = 'true';
8460
+ details.append(summary, list);
8461
+ body.append(details);
8462
+ }
7834
8463
  item.append(body);
8464
+ if (groupKey) updateToastGroup(item);
7835
8465
 
7836
8466
  const close = document.createElement('button');
7837
8467
  close.type = 'button';
7838
8468
  close.className = 'codex-toast-close';
7839
8469
  close.setAttribute('aria-label', tr('dismissNotification'));
7840
- close.textContent = '×';
8470
+ close.innerHTML = Modules.PanelIcons.svg('close', 'codex-close-glyph');
7841
8471
  close.addEventListener('click', () => item.remove());
7842
8472
  item.append(close);
7843
8473
 
@@ -7851,6 +8481,20 @@
7851
8481
  item.remove();
7852
8482
  }, 5200);
7853
8483
  }
8484
+
8485
+ function updateToastGroup(toast) {
8486
+ const list = toast.querySelector('[data-toast-group-items]');
8487
+ const value = String(options.groupItem || '').trim();
8488
+ if (value && !Array.from(list.children).some(item => item.textContent === value)) {
8489
+ const item = document.createElement('li');
8490
+ const code = document.createElement('code');
8491
+ code.textContent = value;
8492
+ item.append(code);
8493
+ list.append(item);
8494
+ }
8495
+ toast.querySelector('.codex-toast-group summary').textContent =
8496
+ options.groupSummary?.(list.children.length) || String(list.children.length);
8497
+ }
7854
8498
  }
7855
8499
 
7856
8500
  function updateProbeNotice(text) {