codex-overleaf-link 2.5.1 → 2.6.5

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 (111) 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 +148 -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 +46 -10
  9. package/extension/src/content/composerPanel.js +92 -12
  10. package/extension/src/content/contentRuntime.js +1114 -321
  11. package/extension/src/content/contextTray.js +8 -2
  12. package/extension/src/content/contextUsageView.js +267 -0
  13. package/extension/src/content/diagnosticsController.js +77 -34
  14. package/extension/src/content/diagnosticsExport.js +1 -1
  15. package/extension/src/content/diagnosticsPanel.js +147 -42
  16. package/extension/src/content/generated/content.bundle.js +5761 -1237
  17. package/extension/src/content/generated/content.bundle.meta.json +6 -3
  18. package/extension/src/content/legacyGlobalRegistry.js +4 -0
  19. package/extension/src/content/localSkillsPanel.js +6 -4
  20. package/extension/src/content/markdownDomRenderer.js +5 -3
  21. package/extension/src/content/markdownText.js +53 -14
  22. package/extension/src/content/modelPicker.js +29 -19
  23. package/extension/src/content/nativeCompatibilityController.js +52 -19
  24. package/extension/src/content/otWarmMirror.js +1 -1
  25. package/extension/src/content/pageBridgeClient.js +7 -7
  26. package/extension/src/content/panelMaintenance.js +10 -2
  27. package/extension/src/content/panelRenderer.js +55 -28
  28. package/extension/src/content/pendingInputView.js +1 -1
  29. package/extension/src/content/projectSettingsCoordinator.js +30 -6
  30. package/extension/src/content/providerSettingsDialog.js +28 -19
  31. package/extension/src/content/recentProjects.js +30 -19
  32. package/extension/src/content/runActivitySummary.js +122 -14
  33. package/extension/src/content/runCompletionMeta.js +194 -6
  34. package/extension/src/content/runController.js +2 -1
  35. package/extension/src/content/runFailureNotice.js +8 -1
  36. package/extension/src/content/runPresence.js +18 -8
  37. package/extension/src/content/runQueueScheduler.js +27 -13
  38. package/extension/src/content/runRecoveryActions.js +2 -1
  39. package/extension/src/content/runResultActions.js +73 -24
  40. package/extension/src/content/runReviewController.js +102 -37
  41. package/extension/src/content/runTimelineView.js +224 -25
  42. package/extension/src/content/scopedPersistencePanelState.js +124 -46
  43. package/extension/src/content/selectionContextView.js +30 -3
  44. package/extension/src/content/sensitivePreflight.js +14 -1
  45. package/extension/src/content/sessionManager.js +49 -3
  46. package/extension/src/content/sessionMenuView.js +54 -18
  47. package/extension/src/content/sessionPersistence.js +25 -5
  48. package/extension/src/content/settingsPanel.js +15 -2
  49. package/extension/src/content/settingsWorkbench.js +127 -89
  50. package/extension/src/content/sharedSessionViewSync.js +13 -4
  51. package/extension/src/content/subagentActivityView.js +23 -7
  52. package/extension/src/content/updateNotice.js +60 -6
  53. package/extension/src/content/writebackOrchestrator.js +301 -72
  54. package/extension/src/content/writingStyleSettings.js +70 -17
  55. package/extension/src/page/compileBridge.js +193 -180
  56. package/extension/src/page/overleafCapabilities.js +4 -1
  57. package/extension/src/page/overleafEditor.js +12 -3
  58. package/extension/src/page/overleafProjectSnapshot.js +56 -3
  59. package/extension/src/page/snapshotRouter.js +75 -66
  60. package/extension/src/page/textCoordinates.js +17 -1
  61. package/extension/src/page/textFileCreator.js +9 -1
  62. package/extension/src/page/treeOperations.js +200 -19
  63. package/extension/src/page/writebackRouter.js +172 -113
  64. package/extension/src/pageBridge.js +124 -73
  65. package/extension/src/popup.js +1 -1
  66. package/extension/src/shared/agentTranscript.js +372 -18
  67. package/extension/src/shared/auditRecords.js +3 -0
  68. package/extension/src/shared/compatibility.js +1 -1
  69. package/extension/src/shared/compileAdapter.js +58 -17
  70. package/extension/src/shared/composerAttachmentDraft.js +27 -7
  71. package/extension/src/shared/contextUsage.js +125 -0
  72. package/extension/src/shared/failureReasons.js +57 -3
  73. package/extension/src/shared/i18n.js +315 -29
  74. package/extension/src/shared/lineReferences.js +38 -31
  75. package/extension/src/shared/pageRpcContract.js +1 -1
  76. package/extension/src/shared/panelIcons.js +39 -0
  77. package/extension/src/shared/projectFiles.js +2 -1
  78. package/extension/src/shared/reviewing.js +130 -1
  79. package/extension/src/shared/runActivityModel.js +70 -4
  80. package/extension/src/shared/runExecutionSnapshot.js +13 -0
  81. package/extension/src/shared/scopedPersistenceBrowserAdapter.js +34 -0
  82. package/extension/src/shared/sessionState.js +63 -4
  83. package/extension/src/shared/sessionTitle.js +14 -1
  84. package/extension/src/shared/sharedSessionBridge.js +81 -13
  85. package/extension/src/shared/staleGuard.js +130 -63
  86. package/extension/src/shared/storageDb.js +35 -3
  87. package/extension/src/shared/storageMigration.js +111 -2
  88. package/extension/src/shared/storageRunActions.js +62 -8
  89. package/extension/src/shared/undoOperations.js +126 -28
  90. package/extension/src/shared/updateRuntimeIdentity.js +16 -2
  91. package/extension/src/shared/writebackSettlement.js +21 -3
  92. package/extension/styles/panel.css +1293 -549
  93. package/native-host/src/bootstrapUpdate.js +52 -16
  94. package/native-host/src/chatBridgeRequest.js +60 -6
  95. package/native-host/src/chatBridgeResponse.js +10 -1
  96. package/native-host/src/chatCompletionsBridge.js +29 -2
  97. package/native-host/src/codexPromptAssembly.js +86 -118
  98. package/native-host/src/codexSessionRunner.js +127 -17
  99. package/native-host/src/debugLog.js +27 -3
  100. package/native-host/src/index.js +42 -11
  101. package/native-host/src/localSkills.js +1 -1
  102. package/native-host/src/mirrorWorkspace.js +47 -3
  103. package/native-host/src/nativeEnvironment.js +27 -5
  104. package/native-host/src/providerContextError.js +20 -0
  105. package/native-host/src/providerRuntime.js +1 -0
  106. package/native-host/src/skills/reviewer-response/SKILL.md +65 -0
  107. package/native-host/src/taskRunnerRuntime.js +28 -8
  108. package/native-host/src/textPatch.js +473 -377
  109. package/native-host/src/updateManager.js +144 -21
  110. package/native-host/src/writingStyleRuntime.js +1 -1
  111. 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';
@@ -667,6 +697,8 @@
667
697
  showPluginConfirm, formatOperationFiles, getAppliedEntries, getSkippedEntries
668
698
  });
669
699
  const writebackOrchestrator = Modules.WritebackOrchestrator.create({
700
+ mirrorFenceStore: root.CodexOverleafModuleRegistry.resolve('ScopedPersistenceBrowserAdapter'),
701
+ getAccountScopeId: () => cachedAccountScopeId,
670
702
  tr,
671
703
  tx,
672
704
  getLocale,
@@ -762,6 +794,7 @@
762
794
  || { providerId: state?.providerId || 'builtin', providerRevision: 0 },
763
795
  readSelectedSpeedInput,
764
796
  getRenderedModelEntries,
797
+ onSelectionRendered: renderContextUsage,
765
798
  persistPanelInputs,
766
799
  closeDiagnosticsMenu,
767
800
  closeCustomInstructionsSettings,
@@ -813,20 +846,19 @@
813
846
  });
814
847
  // v1.8.1 D3: the dashboard was a one-shot render — another tab finishing a
815
848
  // run, clearing history or creating sessions never showed up. Refresh it
816
- // (debounced) when the tab regains visibility or a cross-tab run signal
817
- // arrives while the dashboard view is active.
849
+ // (debounced) on visibility, cross-tab run signals, and shared-history changes.
818
850
  let dashboardRefreshTimer = null;
819
851
  function refreshDashboardSoon() {
820
- if (panel?.dataset.view !== 'recent-projects') {
852
+ if (panel?.dataset.view !== 'recent-projects' || document.visibilityState !== 'visible') {
821
853
  return;
822
854
  }
823
855
  clearTimeout(dashboardRefreshTimer);
824
856
  dashboardRefreshTimer = setTimeout(() => {
825
- if (panel?.dataset.view !== 'recent-projects') {
857
+ if (panel?.dataset.view !== 'recent-projects' || document.visibilityState !== 'visible') {
826
858
  return;
827
859
  }
828
860
  // Never clobber an in-progress inline rename (fleet P2) — retry later.
829
- if (panel.querySelector('.recent-projects-sessions input')) {
861
+ if (panel.querySelector('.recent-projects-session-rename-input')) {
830
862
  refreshDashboardSoon();
831
863
  return;
832
864
  }
@@ -834,18 +866,17 @@
834
866
  const expanded = Array.from(panel.querySelectorAll('[data-project-row-wrap][data-expanded="true"]'))
835
867
  .map(wrap => wrap.getAttribute('data-project-row-wrap'))
836
868
  .filter(Boolean);
837
- const rootEl = panel.querySelector('[data-recent-projects-root]');
838
- recentProjects.renderRecentProjectsVariant({
839
- restoreExpanded: expanded,
840
- restoreScrollTop: rootEl ? rootEl.scrollTop : undefined
841
- }).catch(() => { /* keep last render */ });
869
+ recentProjects.refreshDashboardView({ restoreExpanded: expanded }).catch(() => { /* keep last render */ });
842
870
  }, 400);
843
871
  }
844
872
  document.addEventListener('visibilitychange', () => {
845
873
  if (document.visibilityState === 'visible') {
846
874
  refreshDashboardSoon();
875
+ } else {
876
+ flushComposerDraftOnLeave();
847
877
  }
848
878
  });
879
+ window.addEventListener('pagehide', flushComposerDraftOnLeave);
849
880
 
850
881
  const recentProjects = Modules.RecentProjects.create({
851
882
  ProjectSessionCleanup: Modules.ProjectSessionCleanup,
@@ -887,6 +918,7 @@
887
918
  const MAX_ATTACHMENT_PREVIEW_DATA_URL_CHARS = 768 * 1024;
888
919
  const composerAttachmentController = Modules.ComposerAttachments.createComposerAttachmentController({
889
920
  getPanel: () => panel,
921
+ icons: Modules.PanelIcons,
890
922
  tr,
891
923
  tx,
892
924
  appendPlainLog,
@@ -917,11 +949,14 @@
917
949
  });
918
950
  const contextTrayController = ContextTray.createContextTrayController({
919
951
  root: window,
952
+ icons: Modules.PanelIcons,
920
953
  document,
921
954
  getPanel: () => panel,
922
955
  getState: () => state,
923
956
  setState: nextState => {
924
957
  state = nextState;
958
+ // Paint focus changes before ContextTray waits for durable persistence.
959
+ syncProbeStatus();
925
960
  },
926
961
  saveState,
927
962
  saveStateSoon,
@@ -985,6 +1020,7 @@
985
1020
  let settingsPanelInstance = null;
986
1021
  let diagnosticsPanelInstance = null;
987
1022
  let composerPanelInstance = null;
1023
+ let contextUsageView = null;
988
1024
  let state = null;
989
1025
  let storageKey = LEGACY_STORAGE_KEY;
990
1026
  let globalPreferencesController = null;
@@ -992,9 +1028,14 @@
992
1028
  let runSubmissionPending = false;
993
1029
  let pendingRetryReplacement = null;
994
1030
  let runQueueScheduler = null;
1031
+ let deferredSessionStart = null;
995
1032
  let runInputCoordinator = null;
996
1033
  const activeTurnControl = ActiveTurnControl.create({ chrome });
997
1034
  let saveStateTimer = null;
1035
+ const DRAFT_SAVE_DELAY_MS = 800;
1036
+ const DRAFT_SAVE_MAX_WAIT_MS = 4000;
1037
+ let draftSaveTimer = null;
1038
+ let pendingDraftSave = null;
998
1039
  // Two-phase saveState scheduling. `saveStateInFlight` flips to true while
999
1040
  // an async saveState() is actually writing; `saveStateRunAfterFlight` is
1000
1041
  // set when a fresh saveStateSoon() fires during the in-flight phase. When
@@ -1011,6 +1052,8 @@
1011
1052
  let storageNoticeKeys = new Set();
1012
1053
  let composerSkillInvocation = null;
1013
1054
  let composerDraftRevision = 0;
1055
+ // Page-local provenance only; the draft itself stays shared per conversation.
1056
+ let composerDraftEditedHere = false;
1014
1057
  let slashCodexOverleafSkills = [];
1015
1058
  let slashCodexOverleafSkillsLoaded = false;
1016
1059
  let slashCodexOverleafSkillsLoading = false;
@@ -1033,6 +1076,7 @@
1033
1076
  showPluginToast,
1034
1077
  getPanel: () => panel,
1035
1078
  ensurePanelOpen,
1079
+ getIconUrl: () => chrome.runtime.getURL('assets/icons/codex-overleaf-dialog-icon.png'),
1036
1080
  getPanelRendererInstance: () => panelRendererInstance,
1037
1081
  setBadge: (...args) => PanelRenderer.setBadge(...args),
1038
1082
  setDiagnosticsHealth,
@@ -1051,7 +1095,7 @@
1051
1095
  getBusyState: () => ({
1052
1096
  run: Boolean(currentRunView),
1053
1097
  cancelling: runCancellationRequested,
1054
- storage: saveStateInFlight || Boolean(saveStateTimer),
1098
+ storage: saveStateInFlight || Boolean(saveStateTimer) || Boolean(pendingDraftSave),
1055
1099
  reviewAction: trackedChangeInFlight.size > 0,
1056
1100
  dialog: Boolean(pluginConfirmController?.isOpen())
1057
1101
  }),
@@ -1081,7 +1125,7 @@
1081
1125
  chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
1082
1126
  if (message?.type === 'codex-overleaf/runtime-health-probe') {
1083
1127
  sendResponse?.({
1084
- ok: true,
1128
+ ok: true, runOwnerProtocol: 2, runOwners: [activeTurnControl.getOwner()].filter(Boolean),
1085
1129
  version: CodexOverleafCompatibility?.BUILD_TARGET_VERSION || chrome.runtime.getManifest().version
1086
1130
  });
1087
1131
  return false;
@@ -1146,6 +1190,7 @@
1146
1190
  await refreshAccountScopeId();
1147
1191
  storageKey = getProjectStorageKey(LEGACY_STORAGE_KEY, window.location.href);
1148
1192
  state = normalizeLoadedPanelState(await loadStoredState());
1193
+ composerDraftEditedHere = false;
1149
1194
  await getRunReviewController().recoverInterruptedUndoStatuses();
1150
1195
  initializeRunQueueScheduler();
1151
1196
  recoverInterruptedRunJournals()
@@ -1251,7 +1296,6 @@
1251
1296
  callbacks: {
1252
1297
  onLauncherToggle: () => ensurePanelOpen(),
1253
1298
  onClosePanel: () => closePanel(),
1254
- onRefresh: () => refreshProbe({ userInitiated: true }),
1255
1299
  onNewSession: () => startNewSession(),
1256
1300
  onChangeHistory: () => openChangeHistory(),
1257
1301
  onSettingsClick: () => toggleCustomInstructionsSettings(),
@@ -1273,7 +1317,9 @@
1273
1317
  diagnosticsPanelInstance = DiagnosticsPanel.create({
1274
1318
  container: panelRendererInstance.diagnosticsSlot,
1275
1319
  i18n: { tr },
1320
+ icons: Modules.PanelIcons,
1276
1321
  callbacks: {
1322
+ onRefresh: () => refreshProbe({ userInitiated: true }),
1277
1323
  onBeforeOpen: () => {
1278
1324
  closeModelConfigPopover();
1279
1325
  closeContextTray();
@@ -1311,6 +1357,7 @@
1311
1357
  projectId: getCurrentProjectId(),
1312
1358
  i18n: { tr },
1313
1359
  button: panel.querySelector('[data-custom-instructions-settings]'),
1360
+ getProjectName: () => { try { return recentProjects.readCurrentProjectNameFromDom(); } catch { return ''; } },
1314
1361
  callbacks: {
1315
1362
  onBack: () => closeCustomInstructionsSettings(),
1316
1363
  onInputChange: event => persistPanelInputs(event),
@@ -1351,9 +1398,12 @@
1351
1398
  composerPanelInstance = ComposerPanel.create({
1352
1399
  container: panelRendererInstance.composerSlot,
1353
1400
  i18n: { tr },
1401
+ icons: Modules.PanelIcons,
1354
1402
  attachmentController: composerAttachmentController,
1355
1403
  callbacks: {
1356
1404
  onSubmit: () => safeRunTask(),
1405
+ getTaskScope: getComposerDraftScope,
1406
+ getTaskValue: () => state.task,
1357
1407
  isRunning: () => Boolean(currentRunView),
1358
1408
  onCancel: () => cancelActiveRun(),
1359
1409
  onPaste: handleComposerPaste,
@@ -1369,7 +1419,7 @@
1369
1419
  onAttachClick: () => panel.querySelector('[data-attach-input]')?.click(),
1370
1420
  onAttachInput: handleAttachInputChange,
1371
1421
  onContextRefresh: () => loadContextFiles({ force: true }),
1372
- onModelConfigToggle: () => toggleModelConfigPopover(),
1422
+ onModelConfigToggle: () => { contextUsageView?.close(); toggleModelConfigPopover(); },
1373
1423
  onModelConfigChoiceClick: event => {
1374
1424
  handleModelConfigChoiceClick(event).catch(error => {
1375
1425
  appendPlainLog(tx(`Failed to update model settings: ${error.message}`, `更新模型设置失败:${error.message}`));
@@ -1382,6 +1432,18 @@
1382
1432
  }
1383
1433
  });
1384
1434
 
1435
+ contextUsageView = Modules.ContextUsageView.create({
1436
+ getPanel: () => panel, getLocale, tr,
1437
+ onOpen: () => {
1438
+ closeModelConfigPopover();
1439
+ closeContextTray();
1440
+ closeSlashMenu();
1441
+ },
1442
+ getCompactAvailability,
1443
+ level: Modules.ContextUsage.level,
1444
+ onCompact: () => safeRunTask({ operation: 'compact' })
1445
+ });
1446
+ renderContextUsage();
1385
1447
  installContextDismiss();
1386
1448
  installModelConfigDismiss();
1387
1449
  bindLogAutoFollow();
@@ -1393,7 +1455,7 @@
1393
1455
  primeCodexLocalSkillsCatalog().catch(() => { /* retry through settings, slash, or submission */ });
1394
1456
  }
1395
1457
 
1396
- PanelRenderer.setVisible(panel, true);
1458
+ PanelRenderer.setVisible(panelRendererInstance, true);
1397
1459
  }
1398
1460
 
1399
1461
  // Panel prefetch and first submission share the same official catalog read.
@@ -1559,7 +1621,7 @@
1559
1621
  panelRendererInstance?.setLocale?.({ launcher: tx('Show or hide Codex panel', '显示或隐藏 Codex 边栏'), close: tx('Close Codex panel', '关闭 Codex 边栏') });
1560
1622
 
1561
1623
  setElementTitleAndAria('[data-panel-resize-handle]', tr('resizePanel'), tr('resizePanel'));
1562
- setElementTitleAndAria('[data-refresh]', tr('refreshProbe'), tr('refreshProbe'));
1624
+ setRefreshProbeLoading(panel?.querySelector('[data-refresh]')?.dataset.loading === 'true');
1563
1625
  // State-dependent tooltip: re-derive from the dot's current health so a
1564
1626
  // locale/session refresh doesn't clobber it back to the generic label.
1565
1627
  setDiagnosticsHealth(panel.querySelector('[data-diagnostics-health-dot]')?.dataset.health || 'unknown');
@@ -1610,7 +1672,7 @@
1610
1672
  }
1611
1673
  const reviewToggle = panel.querySelector('.codex-review-toggle');
1612
1674
  if (reviewToggle) {
1613
- reviewToggle.title = tr('requireReviewingTitle');
1675
+ syncReviewingTooltip();
1614
1676
  }
1615
1677
  const recompileToggle = panel.querySelector('.codex-recompile-toggle');
1616
1678
  if (recompileToggle) {
@@ -1633,7 +1695,11 @@
1633
1695
  updateExperimentalOtMenuStatus();
1634
1696
  renderModelConfigChoices();
1635
1697
  updateModelDisplay();
1698
+ // The header switcher's title and accessible name carry localized text too.
1699
+ applySessionLabel();
1636
1700
  renderSessionList();
1701
+ renderContextUsage();
1702
+ if (panel.querySelector('[data-slash-menu]')?.hidden === false) updateSlashMenuForTaskInput();
1637
1703
  renderContextSelection();
1638
1704
  renderContextSummary();
1639
1705
  renderPendingInputs();
@@ -1810,7 +1876,8 @@
1810
1876
  // Capture the click before any preparation can yield to another draft/session.
1811
1877
  readPanelInputs();
1812
1878
  const queuedInput = options.queuedInput || null;
1813
- const task = String(queuedInput?.text || state.task || '').trim();
1879
+ const compact = !queuedInput && options.operation === 'compact';
1880
+ const task = compact ? '/compact' : String(queuedInput?.text || state.task || '').trim();
1814
1881
  if (!task) {
1815
1882
  appendLog(tx('Enter a task first.', '请先输入任务。'));
1816
1883
  return;
@@ -1818,32 +1885,38 @@
1818
1885
  const submittedSessionId = state.activeSessionId;
1819
1886
  const submittedSession = runController.captureSessionContext(state);
1820
1887
  const submittedThreadId = getActiveSession(state)?.codexThreadId || '';
1888
+ if (compact && !submittedThreadId) {
1889
+ showPluginToast(tr('compactNoThread'), { status: 'info' });
1890
+ return;
1891
+ }
1821
1892
  const submittedDraft = state.task;
1822
1893
  const submittedRetryReplacement = pendingRetryReplacement;
1823
1894
  const submittedWritingStyleScope = { accountScopeId: cachedAccountScopeId, projectId: getCurrentProjectId() };
1824
1895
  const submittedPanelState = {
1825
- mode: state.mode,
1896
+ mode: compact ? 'ask' : state.mode,
1826
1897
  providerId: state.providerId,
1827
1898
  model: state.model,
1828
1899
  reasoningEffort: state.reasoningEffort,
1829
1900
  speedTier: state.speedTier,
1830
1901
  autoRecompile: state.autoRecompile !== false,
1831
1902
  requireReviewing: state.requireReviewing === true,
1832
- focusFiles: getActiveFocusFiles(),
1833
- selectionContext: selectionContextView.capture()
1903
+ focusFiles: compact ? [] : getActiveFocusFiles(),
1904
+ selectionContext: compact ? null : selectionContextView.capture()
1834
1905
  };
1835
1906
  const queuedContext = queuedInput
1836
1907
  ? RunInputQueue.normalizeSubmissionContext(queuedInput.payload?.submissionContext) : null;
1837
1908
  const submittedCustomInstructions = queuedContext ? queuedContext.customInstructions : getCustomInstructionsForCurrentProject();
1838
1909
  const submittedSkillLoadingSettings = queuedContext ? queuedContext.skillLoadingSettings : getSkillLoadingSettings();
1839
- const submittedAttachments = queuedInput ? [] : getComposerAttachmentsForRun();
1840
- const submittedSkillInvocation = queuedContext ? queuedContext.skillInvocation : getComposerSkillInvocationForRun();
1910
+ const submittedAttachments = queuedInput || compact ? [] : getComposerAttachmentsForRun();
1911
+ const submittedSkillInvocation = compact ? null : queuedContext ? queuedContext.skillInvocation : getComposerSkillInvocationForRun();
1841
1912
  let submittedEnabledSkillIds = queuedContext ? queuedContext.enabledCodexOverleafSkillIds : getEnabledCodexOverleafSkillIds();
1842
1913
  const submittedSkillEnabled = { ...state.codexOverleafSkillEnabled };
1843
1914
  const submittedLocalSkillEnabled = { ...state.codexLocalSkillEnabled };
1844
1915
  const submittedSelectionRevision = selectionContextView.getRevision?.();
1845
- let submittedEnabledLocalSkillIds = queuedContext ? queuedContext.enabledCodexLocalSkillIds : [];
1846
- if (!queuedInput && submittedSkillLoadingSettings.loadCodexLocalSkills !== false) {
1916
+ let submittedEnabledLocalSkillIds = queuedContext ? queuedContext.enabledCodexLocalSkillIds : compact ? getEnabledCodexLocalSkillIds() : [];
1917
+ // Capture submission first; a save must not adopt text typed while it waits.
1918
+ if (!queuedInput) await flushComposerDraft({ requireDurable: true });
1919
+ if (!compact && !queuedInput && submittedSkillLoadingSettings.loadCodexLocalSkills !== false) {
1847
1920
  const localSkills = await primeCodexLocalSkillsCatalog({ required: true, force: submittedSkillInvocation?.scope === 'codex-local' });
1848
1921
  submittedEnabledLocalSkillIds = localSkills.filter(skill => Object.hasOwn(submittedLocalSkillEnabled, skill.id)
1849
1922
  ? submittedLocalSkillEnabled[skill.id] !== false : skill.enabled !== false).map(skill => skill.id);
@@ -1853,7 +1926,7 @@
1853
1926
  throw new Error(tx('The selected local Codex skill is unavailable or disabled. Select an enabled skill and try again.',
1854
1927
  '所选 Codex 本地技能不可用或已关闭,请选择已启用的技能后重试。'));
1855
1928
  }
1856
- if (!queuedInput && submittedSkillLoadingSettings.loadCodexOverleafSkills !== false) {
1929
+ if (!compact && !queuedInput && submittedSkillLoadingSettings.loadCodexOverleafSkills !== false) {
1857
1930
  const skills = await primeCodexOverleafSkillsCatalog({ required: true });
1858
1931
  submittedEnabledSkillIds = skills.map(skill => String(skill?.id || '').trim())
1859
1932
  .filter(id => id && submittedSkillEnabled[id] !== false);
@@ -1862,18 +1935,19 @@
1862
1935
  const pendingMirror = getPendingMirrorRefresh?.();
1863
1936
  if (pendingMirror) await pendingMirror.catch(() => {});
1864
1937
  await providerSettingsCoordinator.ensureLoaded();
1865
- if (!queuedInput) await writingStyleSettings.ensureLoaded(true);
1938
+ if (!queuedInput && !compact) await writingStyleSettings.ensureLoaded(true);
1866
1939
  if (submittedWritingStyleScope.accountScopeId !== cachedAccountScopeId
1867
1940
  || submittedWritingStyleScope.projectId !== getCurrentProjectId()) {
1868
1941
  throw new Error(tx('The active project changed before submission. Submit the task again.',
1869
1942
  '提交前活动项目已改变,请重新提交任务。'));
1870
1943
  }
1871
- if (!findSessionById(submittedSessionId) || currentRunView) {
1944
+ if (!findSessionById(submittedSessionId) || currentRunView
1945
+ || (compact && findSessionById(submittedSessionId)?.codexThreadId !== submittedThreadId)) {
1872
1946
  throw new Error(tx('The submitted session changed before submission. Submit the task again.',
1873
1947
  '提交所属会话已改变,请重新提交任务。'));
1874
1948
  }
1875
- submittedPanelState.writingStyle = queuedInput ? null : writingStyleSettings.capture();
1876
- const executionSnapshot = queuedInput
1949
+ submittedPanelState.writingStyle = queuedInput || compact ? null : writingStyleSettings.capture();
1950
+ let executionSnapshot = queuedInput
1877
1951
  ? RunExecutionSnapshot.resolveForExecution(
1878
1952
  RunExecutionSnapshot.fromQueuePayload(queuedInput.payload),
1879
1953
  { catalog: providerSettingsCoordinator.getCatalog() }
@@ -1881,8 +1955,8 @@
1881
1955
  : captureCurrentExecutionSnapshot(submittedPanelState);
1882
1956
  // Freeze submitted run identity before the panel can change during the run.
1883
1957
  const submittedMode = executionSnapshot.mode;
1884
- const submittedRequireReviewing = executionSnapshot.requireReviewing === true;
1885
- const replaceRunId = queuedInput ? '' : runController.resolveRetryReplacement({
1958
+ let submittedRequireReviewing = executionSnapshot.requireReviewing === true;
1959
+ const replaceRunId = queuedInput || compact ? '' : runController.resolveRetryReplacement({
1886
1960
  candidate: submittedRetryReplacement,
1887
1961
  activeSessionId: submittedSessionId,
1888
1962
  runs: findSessionById(submittedSessionId)?.runs,
@@ -1892,26 +1966,78 @@
1892
1966
  runCancellationRequested = false;
1893
1967
  runCancellationController = new AbortController();
1894
1968
  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;
1969
+ const runSessionId = submittedSessionId;
1912
1970
  let runAuditDraft = null;
1913
1971
  let localSkillRestorePoint = null;
1914
1972
  try {
1973
+ currentRunView = startRunView({
1974
+ sessionId: submittedSessionId,
1975
+ sessionContext: submittedSession,
1976
+ codexThreadId: submittedThreadId,
1977
+ operation: compact ? 'compact' : undefined,
1978
+ task,
1979
+ mode: submittedMode,
1980
+ model: executionSnapshot.model,
1981
+ reasoningEffort: executionSnapshot.reasoningEffort,
1982
+ speedTier: executionSnapshot.speedTier,
1983
+ executionSnapshot,
1984
+ attachments: submittedAttachments,
1985
+ skillInvocation: submittedSkillInvocation,
1986
+ queueItemId: queuedInput?.id || '',
1987
+ replaceRunId
1988
+ });
1989
+ // Preparation can await project/audit work before a Native request exists.
1990
+ // Give other pages a live owner to check throughout that interval.
1991
+ const bound = await awaitRunStep(activeTurnControl.bindBrowserRun({
1992
+ requestId: ActiveTurnControl.getStartupRequestId(runSessionId, currentRunView.recordId),
1993
+ projectKey: currentRunView.runProjectId, clientRunId: currentRunView.recordId,
1994
+ sessionId: runSessionId, ownerToken: crypto.randomUUID()
1995
+ }));
1996
+ throwIfRunCancellationRequested();
1997
+ if (!bound) throw new Error(tx('Could not start the task. Reload the page and try again.',
1998
+ '无法启动任务,请刷新页面后重试。'));
1999
+ renderContextUsage();
2000
+ if (!queuedInput && !compact && pendingRetryReplacement === submittedRetryReplacement) pendingRetryReplacement = null;
2001
+ if (compact) {
2002
+ announceCrossTabRunStart();
2003
+ // Only the slash-command text is consumed; a draft kept while compacting from the popover stays.
2004
+ if (options.consumeCompactDraft === true && state.activeSessionId === submittedSessionId
2005
+ && /^\/[^\s/]*$/.test(String(submittedDraft || '').trim())
2006
+ && panel?.querySelector('[data-task]')?.value === submittedDraft) {
2007
+ panel.querySelector('[data-task]').value = '';
2008
+ updateSessionById(submittedSessionId, { task: '' });
2009
+ recordSubmittedDraftConsumption(submittedSessionId, submittedDraft);
2010
+ composerDraftEditedHere = false;
2011
+ composerDraftRevision += 1;
2012
+ autosizeTaskTextarea();
2013
+ closeSlashMenu();
2014
+ syncComposerSendAvailability(true);
2015
+ }
2016
+ appendRunEvent({ title: tr('contextUsageCompacting'), status: 'running',
2017
+ activity: { v: 1, kind: 'lifecycle', state: 'running', target: 'context_compaction' } });
2018
+ renderContextUsage();
2019
+ const response = await awaitRunStep(sendTrackedCodexRun({
2020
+ operation: 'compact', task: '/compact', mode: 'ask',
2021
+ projectId: currentRunView.runProjectId, accountScopeId: currentRunView.runAccountScopeId,
2022
+ clientRunId: currentRunView.recordId, clientSessionId: runSessionId,
2023
+ threadId: submittedThreadId, model: executionSnapshot.model,
2024
+ reasoningEffort: executionSnapshot.reasoningEffort, speedTier: executionSnapshot.speedTier,
2025
+ providerSelection: RunExecutionSnapshot.toProviderSelection(executionSnapshot),
2026
+ locale: getLocale(), ...submittedSkillLoadingSettings,
2027
+ enabledCodexOverleafSkillIds: submittedEnabledSkillIds,
2028
+ enabledCodexLocalSkillIds: submittedEnabledLocalSkillIds
2029
+ }));
2030
+ if (!response?.ok) throw Object.assign(new Error(response?.error?.message || 'Compaction failed'), response?.error);
2031
+ if (response.result?.operation !== 'compact' || response.result?.status !== 'completed'
2032
+ || response.result?.threadId !== submittedThreadId) {
2033
+ throw Object.assign(new Error('Compaction completion was not confirmed'), { code: 'codex_compact_unconfirmed' });
2034
+ }
2035
+ throwIfRunCancellationRequested();
2036
+ appendCompletionReport({ conclusion: tr('compactCompleted'), conclusionKey: 'context_compacted',
2037
+ status: 'completed', mode: 'ask', operations: [], applyResults: [] });
2038
+ await finishRunView(tr('compactCompleted'), 'completed');
2039
+ return;
2040
+ }
1915
2041
  if (queuedInput?.id) {
1916
2042
  await runQueueScheduler?.markExecuting(runSessionId, queuedInput.id, currentRunView.recordId);
1917
2043
  }
@@ -1990,7 +2116,30 @@
1990
2116
  await awaitRunStep(pauseOtWarmMirror('run-start'));
1991
2117
  const writeSafety = await awaitRunStep(preflightWriteSafety({
1992
2118
  mode: submittedMode,
1993
- requireReviewing: submittedRequireReviewing
2119
+ requireReviewing: submittedRequireReviewing,
2120
+ onUntracked: async choice => {
2121
+ submittedRequireReviewing = false;
2122
+ // Keep the effective policy identical in dispatch, recovery and Undo consumers.
2123
+ const view = currentRunView;
2124
+ const record = findRunRecord(view.recordId, view.sessionId);
2125
+ executionSnapshot = RunExecutionSnapshot.applyUntrackedDecision(view, record, choice);
2126
+ if (choice === 'disable') {
2127
+ updateSessionById(view.sessionId, { requireReviewing: false });
2128
+ if (state.activeSessionId === view.sessionId) {
2129
+ state.requireReviewing = false;
2130
+ panel.querySelector('[data-require-reviewing]').checked = false;
2131
+ }
2132
+ }
2133
+ appendRunEvent({ title: tr('reviewingUnavailableRunUntracked'), status: 'completed',
2134
+ technicalDetail: { code: 'reviewing_unavailable', submittedRequireReviewing: true,
2135
+ effectiveRequireReviewing: false, choice } });
2136
+ saveStateSoon(0);
2137
+ syncProbeStatus();
2138
+ runAuditDraft = await finalizeAuditDraftPartial(runAuditDraft, {
2139
+ requireReviewing: false, trackingDecision: choice
2140
+ });
2141
+ await flushQueuedSaveState({ requireDurable: true });
2142
+ }
1994
2143
  }));
1995
2144
  if (!writeSafety.ok) {
1996
2145
  await finalizeAuditRecord(runAuditDraft, {
@@ -2001,9 +2150,11 @@
2001
2150
  }
2002
2151
 
2003
2152
  let focusFiles = [...executionSnapshot.focusFiles];
2153
+ const mirrorOwner = currentRunView, mirrorRouteGeneration = spaRouteGeneration;
2004
2154
  const warmStart = await awaitRunStep(resolveWarmRunStart({ focusFiles, mode: submittedMode }));
2155
+ const mirrorFence = await awaitRunStep(writebackOrchestrator.refreshMirrorReuseFence(mirrorOwner.runProjectId, mirrorOwner.runAccountScopeId));
2005
2156
  let project = warmStart.project || null;
2006
- let useExistingMirror = warmStart.useExistingMirror;
2157
+ let useExistingMirror = warmStart.useExistingMirror && !mirrorFence;
2007
2158
  let fileOverlays = warmStart.fileOverlays || null;
2008
2159
  let otWarmStart = warmStart.otWarmStart === true;
2009
2160
  if (!useExistingMirror) {
@@ -2012,6 +2163,18 @@
2012
2163
  status: 'running'
2013
2164
  });
2014
2165
  project = await awaitRunStep(getRunProjectSnapshot());
2166
+ if (mirrorFence) {
2167
+ const assertCurrent = () => {
2168
+ throwIfRunCancellationRequested();
2169
+ if (mirrorOwner !== currentRunView || mirrorRouteGeneration !== spaRouteGeneration
2170
+ || mirrorOwner.runProjectId !== getCurrentProjectId()
2171
+ || mirrorOwner.runAccountScopeId !== cachedAccountScopeId) throw createRunCancellationError();
2172
+ };
2173
+ await awaitRunStep(writebackOrchestrator.syncFencedMirror(mirrorOwner.runProjectId, project, {
2174
+ fence: mirrorFence, assertCurrent,
2175
+ sendNative: payload => nativeCompatibilityController.sendBackgroundNative(payload, { assertCurrent })
2176
+ }));
2177
+ }
2015
2178
  }
2016
2179
  throwIfRunCancellationRequested();
2017
2180
  if (!useExistingMirror) {
@@ -2052,7 +2215,7 @@
2052
2215
  fileCount: (project?.files || []).length
2053
2216
  }
2054
2217
  });
2055
- appendCompletionReport({
2218
+ appendCompletionReport({ conclusionKey: 'full_project_unread',
2056
2219
  conclusion: tx('This run did not continue: the full Overleaf project was not read.', '这轮没有继续:没有读到完整的 Overleaf 项目内容。'),
2057
2220
  status: 'blocked',
2058
2221
  operations: [],
@@ -2180,8 +2343,8 @@
2180
2343
  submittedMode
2181
2344
  })));
2182
2345
 
2183
- // Handle mirror_stale error by retrying with full sync
2184
- if (!response.ok && response.error?.code === 'mirror_stale' && useExistingMirror) {
2346
+ // A stale mirror or contradictory editor overlay requires a fresh read.
2347
+ if (!response.ok && ['mirror_stale', 'document_identity_mismatch'].includes(response.error?.code) && useExistingMirror) {
2185
2348
  appendRunEvent({ title: tr('warmMirrorStaleRetryTitle'), status: 'running' });
2186
2349
  const staleRetry = await awaitRunStep(prepareMirrorStaleRetry({
2187
2350
  project: await awaitRunStep(getRunProjectSnapshot()),
@@ -2239,12 +2402,17 @@
2239
2402
  const userChoice = await showThreadResumeFailedPrompt();
2240
2403
  if (userChoice === 'new') {
2241
2404
  updateSessionById(runSessionId, { codexThreadId: '' });
2405
+ renderContextUsage();
2242
2406
  const StorageDb = Modules.StorageDb;
2243
- if (StorageDb) {
2244
- const record = await StorageDb.getRecord('sessions', runSessionId);
2407
+ // A live session goes through the ordinary writer. An envelope
2408
+ // conflict there must not abort the run that is choosing a new thread.
2409
+ if (findSessionById(runSessionId)) {
2410
+ saveStateSoon();
2411
+ } else if (StorageDb) {
2412
+ const record = await StorageDb.getRecord('sessions', runSessionId, { adopt: false });
2245
2413
  if (record) {
2246
2414
  record.codexThreadId = '';
2247
- await StorageDb.putRecord('sessions', record);
2415
+ await StorageDb.putRecord('sessions', record, { adopt: false });
2248
2416
  }
2249
2417
  }
2250
2418
  appendRunEvent({ title: tx('Creating a new Codex conversation thread.', '正在新建 Codex 会话线程。'), status: 'running' });
@@ -2291,15 +2459,20 @@
2291
2459
  // Structured FailureReason (§9.7): split bridge-availability from
2292
2460
  // Codex-side errors. `native_connection_failed` / `native_unavailable`
2293
2461
  // / `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).
2462
+ // so emit `native_bridge_unavailable` (blocked, retryable). Recognized
2463
+ // Codex failures retain their specific cause; only unmatched errors
2464
+ // fall back to `codex_no_usable_result` (error, retryable).
2297
2465
  // Map native/codex failures onto their SPECIFIC catalog codes — the
2298
2466
  // timeout/not-found/output-limit entries (and their bilingual next
2299
2467
  // steps) existed but were never emitted, so everything collapsed into
2300
2468
  // "no usable result" and pointed users at the wrong recovery.
2301
2469
  const runErrorMessage = String(response.error?.message || '');
2302
- const codexRunFailure = response.error?.code === 'codex_local_skill_unavailable'
2470
+ const codexRunFailure = translated.failureCode === 'codex_context_limit'
2471
+ ? buildContentFailure('codex_context_limit', { path: 'codex.run' }, {
2472
+ technicalMessage: runErrorMessage,
2473
+ evidence: { errorCode: response.error?.code || '' }
2474
+ })
2475
+ : response.error?.code === 'codex_local_skill_unavailable'
2303
2476
  ? buildContentFailure('codex_local_skill_unavailable', { path: 'codex.run' }, { technicalMessage: runErrorMessage })
2304
2477
  : translated.failureCode === 'codex_local_skills_failed'
2305
2478
  ? buildContentFailure('codex_local_skills_failed', { path: 'codex.run' }, { technicalMessage: runErrorMessage })
@@ -2360,7 +2533,7 @@
2360
2533
  detail: response.error
2361
2534
  });
2362
2535
  appendCompletionReport({
2363
- conclusion: translated.conclusion,
2536
+ conclusion: translated.conclusion, conclusionKey: translated.conclusionKey,
2364
2537
  status: response.error?.code === 'project_locked' ? 'blocked' : 'failed',
2365
2538
  operations: [],
2366
2539
  applyResults: [],
@@ -2410,7 +2583,7 @@
2410
2583
  status: 'failed',
2411
2584
  failure: emptyFailure
2412
2585
  });
2413
- appendCompletionReport({
2586
+ appendCompletionReport({ conclusionKey: 'empty_result',
2414
2587
  conclusion: emptyResultConclusion,
2415
2588
  status: 'failed',
2416
2589
  operations: [],
@@ -2439,7 +2612,7 @@
2439
2612
  });
2440
2613
  runSettlementPersistence.applyToCurrentRun(syncOutcome.settlement);
2441
2614
  if (runCancellationRequested) {
2442
- throw createRunCancellationError();
2615
+ throw Object.assign(createRunCancellationError(), { writebackOutcome: syncOutcome });
2443
2616
  }
2444
2617
  await finalizeAuditRecord(runAuditDraft, {
2445
2618
  ...(syncOutcome.audit || {}),
@@ -2514,12 +2687,14 @@
2514
2687
  if (returnedThreadId && returnedThreadId !== codexThreadId) {
2515
2688
  updateSessionById(runSessionId, { codexThreadId: returnedThreadId });
2516
2689
  const StorageDb = Modules.StorageDb;
2517
- if (StorageDb) {
2518
- const record = await StorageDb.getRecord('sessions', runSessionId);
2690
+ // The live session is saved below with its latest draft. Writing an
2691
+ // older DB snapshot here can give stale text a newer timestamp.
2692
+ if (StorageDb && !findSessionById(runSessionId)) {
2693
+ const record = await StorageDb.getRecord('sessions', runSessionId, { adopt: false });
2519
2694
  if (record) {
2520
2695
  record.codexThreadId = returnedThreadId;
2521
2696
  record.updatedAt = new Date().toISOString();
2522
- await StorageDb.putRecord('sessions', record);
2697
+ await StorageDb.putRecord('sessions', record, { adopt: false });
2523
2698
  }
2524
2699
  }
2525
2700
  }
@@ -2540,17 +2715,46 @@
2540
2715
  }
2541
2716
  } catch (error) {
2542
2717
  if (runCancellationRequested || isRunCancellationError(error)) {
2543
- appendRunCancelledReport();
2718
+ if (currentRunView?.reviewingUnavailablePreflight) {
2719
+ appendCompletionReport({ conclusionKey: 'launch_not_started',
2720
+ conclusion: tx('This task did not start: Codex did not run and no files were written.', '这轮任务没有开始:Codex 没有运行,也没有写入文件。'),
2721
+ status: 'blocked', operations: [], applyResults: [],
2722
+ failure: buildContentFailure('reviewing_unavailable', {}, {
2723
+ changedDocument: false, evidence: { declinedChoice: true }
2724
+ }),
2725
+ nextStep: tr('failureReason_reviewing_unavailable_cancelled_next') });
2726
+ } else {
2727
+ runSettlementPersistence.applyToCurrentRun(error.writebackOutcome?.settlement);
2728
+ appendRunCancelledReport(error.writebackOutcome);
2729
+ }
2544
2730
  await finishRunView(tx('Cancelled', '已取消'), 'cancelled');
2545
2731
  void finalizeAuditRecord(runAuditDraft, { resultStatus: 'cancelled' });
2546
2732
  return;
2547
2733
  }
2734
+ if (compact) {
2735
+ const key = error.code === 'codex_compact_unsupported' ? 'compactUnsupported'
2736
+ : ['project_locked', 'codex_compact_thread_busy'].includes(error.code) ? 'compactBusy' : 'compactFailed';
2737
+ const contextLimit = FailureReasons.isContextLimitError(error.message, error.code)
2738
+ ? translateRawError(error.message, { locale: getLocale(), code: error.code }) : null;
2739
+ const failure = contextLimit ? buildContentFailure('codex_context_limit', { path: 'codex.run' }, {
2740
+ technicalMessage: error.message, evidence: { errorCode: error.code || '' }
2741
+ }) : undefined;
2742
+ const conclusion = contextLimit?.conclusion || tr(key);
2743
+ appendRunEvent({ title: conclusion, status: 'failed', failure,
2744
+ technicalDetail: { code: error.code || '', message: error.message } });
2745
+ appendCompletionReport({ conclusion, conclusionKey: contextLimit?.conclusionKey || (key === 'compactUnsupported'
2746
+ ? 'compact_unsupported' : key === 'compactBusy' ? 'compact_busy' : 'compact_failed'),
2747
+ nextStep: contextLimit?.nextStep, failure,
2748
+ status: 'failed', mode: 'ask', operations: [], applyResults: [] });
2749
+ await finishRunView(conclusion, 'failed');
2750
+ return;
2751
+ }
2548
2752
  // codexReturned: assistantMessage arrived on the stream before the
2549
2753
  // exception escaped, so Codex's answer is preserved in the conversation
2550
2754
  // — the fallback conclusion must reflect that instead of saying
2551
2755
  // 'local Codex returned no usable result'.
2552
2756
  const codexReturned = Boolean(getAssistantAnswerForCurrentRun());
2553
- const translated = translateRawError(error.message, { mode: submittedMode, locale: getLocale(), codexReturned });
2757
+ const translated = translateRawError(error.message, { mode: submittedMode, locale: getLocale(), code: error.code, codexReturned });
2554
2758
  // When translateRawError maps the raw error to a FailureReason code,
2555
2759
  // attach the structured failure to the run-event and completion-report
2556
2760
  // so the run record carries machine-readable failure data alongside
@@ -2578,7 +2782,7 @@
2578
2782
  }
2579
2783
  });
2580
2784
  appendCompletionReport({
2581
- conclusion: translated.conclusion,
2785
+ conclusion: translated.conclusion, conclusionKey: translated.conclusionKey,
2582
2786
  status: 'failed',
2583
2787
  operations: [],
2584
2788
  applyResults: [],
@@ -2592,6 +2796,10 @@
2592
2796
  blockedFiles: [{ path: 'task', reason: error.message }]
2593
2797
  });
2594
2798
  await finishRunView(tx('Task failed', '任务失败'), 'failed');
2799
+ // A failed initial card render must still leave a visible terminal record.
2800
+ if (currentRunView?.sessionId === state.activeSessionId && !getCurrentRunViewForRender()) {
2801
+ renderRunHistory({ preserveScroll: true });
2802
+ }
2595
2803
  } finally {
2596
2804
  const settledView = currentRunView;
2597
2805
  const settledRecord = settledView ? findRunRecord(settledView.recordId, settledView.sessionId) : null;
@@ -2606,9 +2814,15 @@
2606
2814
  nativeChannel.clearActiveRequest();
2607
2815
  stopRunElapsedTick();
2608
2816
  await flushQueuedSaveState().catch(() => {});
2817
+ // The card is already settled. Retain the owner while Native stops so
2818
+ // neither ordinary promotion nor a deferred session start races its lock.
2819
+ if (settlement.continueQueue) {
2820
+ settlement.continueQueue = await waitForCancelledNativeRun(settledView);
2821
+ }
2609
2822
  currentRunView = null;
2610
2823
  runCancellationRequested = false;
2611
2824
  runCancellationController = null;
2825
+ syncComposerSendAvailability();
2612
2826
  activeTurnControl.release(settlement.requestId);
2613
2827
  if (isExperimentalOtEnabled()) {
2614
2828
  try { await resumeOtWarmMirror('run-settled'); }
@@ -2647,6 +2861,7 @@
2647
2861
  reasoningEffort: executionSnapshot.reasoningEffort,
2648
2862
  speedTier: executionSnapshot.speedTier,
2649
2863
  session: currentRunView?.sessionContext,
2864
+ locale: getLocale(),
2650
2865
  clientRunId: currentRunView?.recordId || '',
2651
2866
  clientSessionId: runSessionId,
2652
2867
  accountScopeId: currentRunView?.runAccountScopeId || '',
@@ -2688,7 +2903,7 @@
2688
2903
  failure: translatedFailure
2689
2904
  });
2690
2905
  appendCompletionReport({
2691
- conclusion: translated.conclusion,
2906
+ conclusion: translated.conclusion, conclusionKey: translated.conclusionKey,
2692
2907
  status: 'failed',
2693
2908
  operations: [],
2694
2909
  applyResults: [],
@@ -2706,7 +2921,7 @@
2706
2921
  }
2707
2922
 
2708
2923
  const assistantMessage = response.result?.assistantMessage || getAssistantAnswerForCurrentRun();
2709
- appendCompletionReport({
2924
+ appendCompletionReport({ conclusionKey: !assistantMessage ? 'skill_installer_completed' : undefined,
2710
2925
  conclusion: assistantMessage || tx('Skill installer completed.', 'Skill installer 已完成。'),
2711
2926
  status: 'completed',
2712
2927
  operations: [],
@@ -2740,11 +2955,14 @@
2740
2955
  if (returnedThreadId && returnedThreadId !== codexThreadId) {
2741
2956
  updateSessionById(runSessionId, { codexThreadId: returnedThreadId });
2742
2957
  const StorageDb = Modules.StorageDb;
2743
- if (StorageDb) {
2744
- const record = await StorageDb.getRecord('sessions', runSessionId);
2958
+ // The installer already finished; a later envelope conflict only delays this save.
2959
+ if (findSessionById(runSessionId)) {
2960
+ saveStateSoon();
2961
+ } else if (StorageDb) {
2962
+ const record = await StorageDb.getRecord('sessions', runSessionId, { adopt: false });
2745
2963
  if (record) {
2746
2964
  record.codexThreadId = returnedThreadId;
2747
- await StorageDb.putRecord('sessions', record);
2965
+ await StorageDb.putRecord('sessions', record, { adopt: false });
2748
2966
  }
2749
2967
  }
2750
2968
  }
@@ -2758,8 +2976,6 @@
2758
2976
  if (mode === 'ask') {
2759
2977
  return { ok: true, skipped: true };
2760
2978
  }
2761
- const method = requireReviewing ? 'ensureReviewing' : 'ensureEditing';
2762
-
2763
2979
  appendRunEvent({
2764
2980
  title: requireReviewing
2765
2981
  ? tx('Checking Overleaf Reviewing/Track Changes before starting.', '正在确认 Overleaf 留痕状态。')
@@ -2767,30 +2983,67 @@
2767
2983
  status: 'running'
2768
2984
  });
2769
2985
 
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
- };
2986
+ const owner = currentRunView;
2987
+ const signal = runCancellationController?.signal;
2988
+ const isCurrent = () => owner === currentRunView && !signal?.aborted && !runCancellationRequested
2989
+ && owner?.runProjectId === getCurrentProjectId() && owner?.runAccountScopeId === cachedAccountScopeId;
2990
+ const assertCurrent = () => { if (!isCurrent()) throw createRunCancellationError(); };
2991
+ assertCurrent();
2992
+ if (requireReviewing) {
2993
+ // Another tab may have observed this project's capability since hydration.
2994
+ const prefs = await Modules.StorageMigration.loadPrefs(owner.runAccountScopeId, owner.runProjectId);
2995
+ assertCurrent();
2996
+ const incoming = prefs?.reviewingAvailabilityByProject?.[owner.runProjectId];
2997
+ const current = projectSettingsCoordinator.getReviewingAvailability();
2998
+ if (incoming && (!current || incoming.observedAt > current.observedAt
2999
+ || (incoming.observedAt === current.observedAt && incoming.status === 'available'))) {
3000
+ state = { ...state, reviewingAvailabilityByProject: {
3001
+ ...state.reviewingAvailabilityByProject, [owner.runProjectId]: incoming
3002
+ } };
3003
+ syncProbeStatus();
3004
+ }
2789
3005
  }
3006
+ let result = await Modules.Reviewing.resolveWritePolicy({ requireReviewing,
3007
+ knownUnavailable: isReviewingKnownUnavailable() }, {
3008
+ assertCurrent,
3009
+ ensureMode: async required => {
3010
+ try {
3011
+ return await callPageBridge(required ? 'ensureReviewing' : 'ensureEditing', {
3012
+ waitMs: 1800, runProjectId: owner.runProjectId
3013
+ });
3014
+ } catch (error) {
3015
+ assertCurrent();
3016
+ return { ok: false, code: error?.code, reason: error?.message, reviewing: null };
3017
+ }
3018
+ },
3019
+ observe: async (status, evidence) => {
3020
+ projectSettingsCoordinator.observeReviewingAvailability(status, evidence);
3021
+ syncProbeStatus();
3022
+ // Publish a detected paywall before waiting for the user's decision.
3023
+ if (status === 'unavailable') await flushQueuedSaveState({ requireDurable: true });
3024
+ assertCurrent();
3025
+ },
3026
+ choose: () => {
3027
+ owner.reviewingUnavailablePreflight = true;
3028
+ return showPluginConfirm({
3029
+ title: tr('reviewingUnavailableTitle'),
3030
+ message: tr('reviewingUnavailableChoiceMessage'),
3031
+ choices: [
3032
+ { value: 'continue', label: tr('reviewingUnavailableContinue') },
3033
+ { value: 'disable', label: tr('reviewingUnavailableDisable'), secondary: true },
3034
+ { value: 'cancel', label: tr('confirmDefaultCancel') }
3035
+ ],
3036
+ signal, isCurrent, timeoutMs: REVIEWING_CONFIRM_TIMEOUT_MS
3037
+ });
3038
+ },
3039
+ applyUntracked: choice => { assertCurrent(); return options.onUntracked(choice); }
3040
+ });
3041
+ if (result.ok) owner.reviewingUnavailablePreflight = false;
3042
+ const effectiveRequireReviewing = result.requireReviewing ?? requireReviewing;
2790
3043
 
2791
3044
  if (result?.ok) {
2792
3045
  appendRunEvent({
2793
- title: requireReviewing
3046
+ title: effectiveRequireReviewing
2794
3047
  ? (result.activated
2795
3048
  ? tx('Overleaf Reviewing/Track Changes is now on. Starting the task.', '已开启 Overleaf 留痕,开始处理任务。')
2796
3049
  : tx('Overleaf Reviewing/Track Changes is already on. Starting the task.', 'Overleaf 留痕已经开启,开始处理任务。'))
@@ -2802,10 +3055,23 @@
2802
3055
  return result;
2803
3056
  }
2804
3057
 
2805
- const reason = localizeVisibleReason(result?.reason || (requireReviewing
3058
+ const unavailable = result?.code === 'reviewing_unavailable';
3059
+ if (unavailable && !result.failure) result.failure = buildContentFailure('reviewing_unavailable', {}, {
3060
+ changedDocument: false, evidence: result.evidence
3061
+ });
3062
+ const reason = unavailable ? tr('failureReason_reviewing_unavailable_user') : localizeVisibleReason(result?.reason || (effectiveRequireReviewing
2806
3063
  ? tx('Overleaf did not return track changes status', 'Overleaf 没有返回留痕状态')
2807
3064
  : tx('Overleaf did not return Editing mode status', 'Overleaf 没有返回 Editing 模式状态')));
2808
- const nextStep = requireReviewing
3065
+ // Affirmative choices retain trackingDecision even if the Editing check fails.
3066
+ const declinedReviewingChoice = owner.reviewingUnavailablePreflight === true && !result.trackingDecision;
3067
+ const reportFailure = unavailable && declinedReviewingChoice
3068
+ ? buildContentFailure('reviewing_unavailable', {}, {
3069
+ ...result.failure, changedDocument: false,
3070
+ evidence: { ...result.failure?.evidence, declinedChoice: true }
3071
+ }) : result?.failure;
3072
+ const nextStep = unavailable ? tr(declinedReviewingChoice
3073
+ ? 'failureReason_reviewing_unavailable_cancelled_next'
3074
+ : 'failureReason_reviewing_unavailable_next') : effectiveRequireReviewing
2809
3075
  ? tx(
2810
3076
  '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
3077
  '你可能没有权限,或 Overleaf 当前页面没有暴露切换入口。可以手动切到 Reviewing 后重试,或关闭“留痕”再运行。'
@@ -2815,7 +3081,7 @@
2815
3081
  '你可能没有权限,或 Overleaf 当前页面没有暴露切换入口。请在 Overleaf 手动切到 Editing 后重试。'
2816
3082
  );
2817
3083
  appendRunEvent({
2818
- title: requireReviewing
3084
+ title: effectiveRequireReviewing
2819
3085
  ? tx('Task not started: could not enable Overleaf Reviewing/Track Changes.', '任务未开始:无法开启 Overleaf 留痕。')
2820
3086
  : tx('Task not started: could not switch to Overleaf Editing.', '任务未开始:无法切换到 Overleaf Editing。'),
2821
3087
  status: 'failed',
@@ -2824,24 +3090,25 @@
2824
3090
  [tr('detailNext')]: nextStep
2825
3091
  },
2826
3092
  technicalDetail: {
2827
- reason,
3093
+ reason, code: result?.code, evidence: result?.evidence, failure: result?.failure,
2828
3094
  reviewing: result?.reviewing || null
2829
3095
  }
2830
3096
  });
2831
- appendCompletionReport({
3097
+ appendCompletionReport({ conclusionKey: 'launch_not_started',
2832
3098
  conclusion: tx('This task did not start: Codex did not run and no files were written.', '这轮任务没有开始:Codex 没有运行,也没有写入文件。'),
2833
3099
  status: 'blocked',
2834
3100
  operations: [],
2835
3101
  applyResults: [],
2836
- nextStep
3102
+ nextStep,
3103
+ reason: result?.code, failure: reportFailure
2837
3104
  });
2838
- const finishTitle = requireReviewing
3105
+ const finishTitle = effectiveRequireReviewing
2839
3106
  ? tx('Not started: could not enable Track Changes', '未开始:无法开启留痕')
2840
3107
  : tx('Not started: could not switch to Editing', '未开始:无法切换到 Editing');
2841
3108
  await finishRunView(finishTitle, 'failed');
2842
3109
  return {
2843
- ok: false,
2844
- reason,
3110
+ ...result, ok: false,
3111
+ reason: result?.code || reason,
2845
3112
  reviewing: result?.reviewing || null
2846
3113
  };
2847
3114
  }
@@ -2854,17 +3121,25 @@
2854
3121
  return runController.truncateSessionHistoryText(value, maxLength);
2855
3122
  }
2856
3123
 
2857
- function safeRunTask() {
3124
+ function safeRunTask(options = {}) {
3125
+ if (sessionManager.isTransitioning()) return;
2858
3126
  if (updateIdleClient?.isApplying()) {
2859
3127
  showPluginToast(tx('An update is being applied. Codex will be available again after reload.', '正在应用更新,重新加载后即可继续使用 Codex。'), { status: 'info' });
2860
3128
  return;
2861
3129
  }
3130
+ const submittedCompactCommand = options.operation !== 'compact'
3131
+ && panel?.querySelector('[data-task]')?.value.trim() === '/compact';
3132
+ const compact = options.operation === 'compact' || submittedCompactCommand;
3133
+ if (compact && (currentRunView || runSubmissionPending)) {
3134
+ showPluginToast(tr('compactBusy'), { status: 'info' });
3135
+ return;
3136
+ }
2862
3137
  if (currentRunView) {
2863
3138
  return queueComposerInput();
2864
3139
  }
2865
3140
  if (runSubmissionPending) return;
2866
3141
  runSubmissionPending = true;
2867
- runTask().catch(error => {
3142
+ runTask(compact ? { operation: 'compact', consumeCompactDraft: options.consumeCompactDraft === true || submittedCompactCommand } : {}).catch(error => {
2868
3143
  if (!currentRunView) {
2869
3144
  setRunning(false);
2870
3145
  nativeChannel.clearActiveRequest();
@@ -2896,7 +3171,7 @@
2896
3171
  getActiveSession: () => getActiveSession(state),
2897
3172
  setQueue: (sessionId, pendingInputs) => updateSessionById(sessionId, { pendingInputs }),
2898
3173
  getCurrentRun: () => currentRunView,
2899
- canStart: sessionId => state?.activeSessionId === sessionId && isProjectEditorRoute(window.location),
3174
+ canStart: canStartQueuedSession,
2900
3175
  readInputs: readPanelInputs,
2901
3176
  getTask: () => panel?.querySelector('[data-task]')?.value || '',
2902
3177
  hasAttachments: () => getComposerAttachmentsForRun().length > 0,
@@ -2916,6 +3191,7 @@
2916
3191
  }),
2917
3192
  clearTask: () => {
2918
3193
  panel.querySelector('[data-task]').value = '';
3194
+ composerDraftEditedHere = false;
2919
3195
  selectionContextView.clear();
2920
3196
  updateSessionById(state.activeSessionId, { task: '' });
2921
3197
  autosizeTaskTextarea();
@@ -2926,6 +3202,8 @@
2926
3202
  RunExecutionSnapshot.fromQueuePayload(item.payload),
2927
3203
  { catalog: providerSettingsCoordinator.getCatalog() }
2928
3204
  );
3205
+ // Promotion uses its captured snapshot; this line still describes the current draft.
3206
+ syncProbeStatus();
2929
3207
  },
2930
3208
  run: item => runTask({ queuedInput: item }),
2931
3209
  getGuideScope: () => ({ projectId: getCurrentProjectId(), accountScopeId: cachedAccountScopeId, generation: spaRouteGeneration }),
@@ -2978,7 +3256,17 @@
2978
3256
  });
2979
3257
  }
2980
3258
 
3259
+ function canStartQueuedSession(sessionId) {
3260
+ if (state?.activeSessionId !== sessionId || !isProjectEditorRoute(window.location)) return false;
3261
+ if (sessionManager.isTransitioning()) {
3262
+ deferredSessionStart = { sessionId, scope: getComposerDraftScope() };
3263
+ return 'session_transition';
3264
+ }
3265
+ return true;
3266
+ }
3267
+
2981
3268
  function queueComposerInput() {
3269
+ if (sessionManager.isTransitioning()) return;
2982
3270
  if (getSkillLoadingSettings().loadCodexLocalSkills !== false && !Array.isArray(getCodexLocalSkillsCatalog())) {
2983
3271
  primeCodexLocalSkillsCatalog({ required: true }).catch(() => {});
2984
3272
  showPluginToast(tx('Local Codex skills are still loading. Try submitting again when loading finishes.',
@@ -3018,18 +3306,21 @@
3018
3306
  // codex_cancelled instead of grinding through the rest of the queue.
3019
3307
  // Without the page-side signal, a cancel during writeback waits up to
3020
3308
  // the applyOperations content-side timeout (30s) before the run settles.
3021
- const activeRequestId = nativeChannel.getActiveRequestId();
3309
+ const activeRequestId = currentRunView.nativeRequestId;
3022
3310
  const projectKey = currentRunView?.runProjectId || getCurrentProjectId() || '';
3023
3311
 
3024
3312
  const cancelTargets = [];
3025
- if (activeRequestId || projectKey) {
3313
+ if (activeRequestId != null && activeRequestId !== '') {
3026
3314
  cancelTargets.push(sendBackgroundNative({
3027
3315
  method: 'codex.cancel',
3028
3316
  params: {
3029
- requestId: activeRequestId || undefined,
3317
+ requestId: activeRequestId,
3030
3318
  projectKey: projectKey || undefined
3031
3319
  }
3032
3320
  }));
3321
+ } else {
3322
+ // Preparation has no Native run to cancel; the local signal prevents dispatch.
3323
+ cancelTargets.push(Promise.resolve(null));
3033
3324
  }
3034
3325
  // The page-side cancel is best-effort — it only matters if a writeback
3035
3326
  // is currently in flight. Swallow errors / unavailable bridge so the
@@ -3116,6 +3407,23 @@
3116
3407
  });
3117
3408
  }
3118
3409
 
3410
+ async function waitForCancelledNativeRun(runView) {
3411
+ const pending = runView?.pendingNativeRun;
3412
+ if (!pending) return true;
3413
+ const CANCELLED_NATIVE_SETTLEMENT_TIMEOUT_MS = 20000;
3414
+ let timer;
3415
+ try {
3416
+ return await Promise.race([
3417
+ pending.then(() => true, () => true),
3418
+ new Promise(resolve => {
3419
+ timer = setTimeout(() => resolve(false), CANCELLED_NATIVE_SETTLEMENT_TIMEOUT_MS);
3420
+ })
3421
+ ]);
3422
+ } finally {
3423
+ clearTimeout(timer);
3424
+ }
3425
+ }
3426
+
3119
3427
  function isRunCancellationError(error = {}) {
3120
3428
  return error.code === 'codex_cancelled' || /cancelled by the user|was cancelled/i.test(error.message || '');
3121
3429
  }
@@ -3218,6 +3526,7 @@
3218
3526
  const params = runController.buildCodexRunParams({
3219
3527
  currentProjectId: getCurrentProjectId(),
3220
3528
  state: submittedState,
3529
+ locale: getLocale(),
3221
3530
  task,
3222
3531
  project,
3223
3532
  useExistingMirror,
@@ -3271,25 +3580,33 @@
3271
3580
  if (!compatibilityGate.ok) {
3272
3581
  return compatibilityGate.response;
3273
3582
  }
3583
+ if (params.operation === 'compact' && compatibilityGate.compatibility?.native?.capabilities?.threadCompact !== true) {
3584
+ return { ok: false, error: { code: 'codex_compact_unsupported', message: 'This Native host does not support context compaction.' } };
3585
+ }
3274
3586
  const tracked = nativeChannel.sendNativeTracked(nativeCompatibilityController.attachEvidence({
3275
3587
  method: 'codex.run',
3276
3588
  params
3277
3589
  }, compatibilityGate.compatibility));
3278
- if (currentRunView) {
3279
- currentRunView.nativeRequestId = tracked.id;
3280
- const record = findRunRecord(currentRunView.recordId, currentRunView.sessionId);
3590
+ if (owner) {
3591
+ owner.nativeRequestId = tracked.id;
3592
+ // Keep the actual response separate from awaitRunStep's local abort race.
3593
+ owner.pendingNativeRun = tracked.promise;
3594
+ const record = findRunRecord(owner.recordId, owner.sessionId);
3281
3595
  if (record) {
3282
3596
  record.nativeRequestId = tracked.id;
3283
3597
  }
3284
3598
  activeTurnControl.bind({
3285
3599
  requestId: tracked.id,
3286
- projectKey: currentRunView.runProjectId,
3287
- clientRunId: currentRunView.recordId,
3288
- sessionId: currentRunView.sessionId
3600
+ projectKey: owner.runProjectId,
3601
+ clientRunId: owner.recordId,
3602
+ sessionId: owner.sessionId
3289
3603
  });
3290
3604
  saveStateSoon();
3291
3605
  }
3292
3606
  return tracked.promise.finally(() => {
3607
+ if (owner?.pendingNativeRun === tracked.promise) {
3608
+ owner.pendingNativeRun = null;
3609
+ }
3293
3610
  if (currentRunView?.nativeRequestId === tracked.id) {
3294
3611
  currentRunView.activeTurn = null;
3295
3612
  renderPendingInputs();
@@ -3298,58 +3615,44 @@
3298
3615
  }
3299
3616
 
3300
3617
  async function recoverInterruptedRunJournals() {
3301
- const projectKey = getCurrentProjectId();
3618
+ const projectKey = getCurrentProjectId(), accountScopeId = cachedAccountScopeId;
3619
+ const generation = spaRouteGeneration;
3620
+ const isCurrent = () => generation === spaRouteGeneration
3621
+ && projectKey === getCurrentProjectId() && accountScopeId === cachedAccountScopeId;
3302
3622
  const recovery = await activeTurnControl.recoverJournals({
3303
3623
  projectKey,
3304
- accountScopeId: cachedAccountScopeId,
3624
+ accountScopeId,
3625
+ isCurrent,
3626
+ onRecheck: async () => {
3627
+ if (!isCurrent()) return;
3628
+ await recoverInterruptedRunJournals();
3629
+ if (isCurrent()) applyStateToPanel();
3630
+ },
3305
3631
  requireOwnerLost: Modules.StorageDb.sharedSessionsEnabled?.() === true,
3306
3632
  normalizeInterruptedRun: record => Object.assign(record, Modules.SessionState.normalizeRuns([record], {
3307
3633
  restoreRunningRuns: true,
3308
3634
  locale: getLocale()
3309
3635
  })[0]),
3310
3636
  findSession: findSessionById,
3637
+ listSessions: () => state?.sessions || [],
3638
+ isLiveRun: (record, session) => currentRunView?.recordId === record.id
3639
+ && currentRunView.sessionId === session.id && currentRunView.runProjectId === projectKey,
3311
3640
  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: []
3641
+ createInterruptedRun: journal => ActiveTurnControl.createInterruptedRun(journal, projectKey, tr('restoredRunStoppedStatus')),
3642
+ onRawEvent: (raw, { record, session, sequence }) => storeContextUsage(raw, record, session, sequence),
3643
+ mapEvent: (raw, journal) => ActiveTurnControl.mapRecoveredEvent(raw, journal, {
3644
+ mapActivity: value => mapAgentEventToActivity(value, { locale: getLocale() }),
3645
+ sanitizeText: sanitizeAssistantVisibleText, sanitizeValue: sanitizeAssistantVisibleValue
3327
3646
  }),
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
3647
  upsertStream: upsertRunStreamRecordEvent,
3346
3648
  pauseSessionQueue: (session, reason) => {
3347
3649
  session.pendingInputs = RunInputQueue.pauseAll(session.pendingInputs || [], reason);
3348
3650
  },
3349
3651
  maxEvents: MAX_RUN_EVENTS
3350
3652
  });
3653
+ if (!isCurrent()) return;
3351
3654
  if (recovery.changed) {
3352
- if (await saveState({ recoveryRuns: recovery.recoveredRuns }) !== true) return;
3655
+ if (await saveState({ recoveryRuns: recovery.recoveredRuns }) !== true || !isCurrent()) return;
3353
3656
  for (const requestId of recovery.acknowledgeIds) {
3354
3657
  await activeTurnControl.acknowledge(requestId).catch(() => false);
3355
3658
  }
@@ -3375,6 +3678,7 @@
3375
3678
  sessionId: input.sessionId || '',
3376
3679
  turnId: currentRunView?.recordId || '',
3377
3680
  mode: input.mode || executionSnapshot.mode,
3681
+ requireReviewing: executionSnapshot.requireReviewing,
3378
3682
  ...providerSettingsCoordinator.getProviderSnapshot(executionSnapshot.providerId),
3379
3683
  providerRevision: executionSnapshot.providerRevision,
3380
3684
  model: executionSnapshot.model,
@@ -3445,19 +3749,20 @@
3445
3749
  }).run(options);
3446
3750
  }
3447
3751
 
3448
- function appendRunCancelledReport() {
3449
- const hasQueuedFollowUp = (getActiveSession(state)?.pendingInputs || [])
3752
+ function appendRunCancelledReport(writebackOutcome = {}, continueQueue = true) {
3753
+ const hasQueuedFollowUp = continueQueue && (getActiveSession(state)?.pendingInputs || [])
3450
3754
  .some(item => item?.status === 'queued');
3451
3755
  appendRunEvent({
3452
3756
  title: tx('Current Codex task was cancelled.', '已中断当前 Codex 任务。'),
3453
3757
  status: 'info',
3454
3758
  activity: { v: 1, kind: 'lifecycle', state: 'cancelled', target: 'cancelled' }
3455
3759
  });
3456
- appendCompletionReport({
3760
+ appendCompletionReport({ conclusionKey: 'cancelled',
3457
3761
  conclusion: tx('This run was cancelled. It did not continue syncing or writing to Overleaf.', '这轮已中断,没有继续同步或写入 Overleaf。'),
3458
3762
  status: 'cancelled',
3459
- operations: [],
3460
- applyResults: [],
3763
+ operations: writebackOutcome.operations || [],
3764
+ applyResults: writebackOutcome.applied ? [writebackOutcome.applied] : [],
3765
+ saveVerification: writebackOutcome.saveVerification,
3461
3766
  nextStep: hasQueuedFollowUp
3462
3767
  ? tx('The first queued follow-up will start next.', '接下来会自动启动队首的后续任务。')
3463
3768
  : tx('Edit the task and run again.', '可以修改任务后重新运行。')
@@ -3482,22 +3787,36 @@
3482
3787
  const running = typeof runningOverride === 'boolean'
3483
3788
  ? runningOverride
3484
3789
  : Boolean(currentRunView);
3485
- const action = running && !hasInput ? 'cancel' : 'send';
3790
+ const runningInActiveSession = running && (!currentRunView || currentRunView.sessionId === state?.activeSessionId);
3791
+ const transitioning = sessionManager.isTransitioning();
3792
+ const action = runningInActiveSession && (!hasInput || transitioning) ? 'cancel' : 'send';
3793
+ const attachmentsLoading = composerAttachmentController.hasPendingReads();
3486
3794
  const label = action === 'cancel'
3487
3795
  ? tr('cancelRun')
3488
- : running
3489
- ? tr('queueNextInput')
3490
- : tr('send');
3796
+ : attachmentsLoading
3797
+ ? tr('attachmentsLoading')
3798
+ : running && hasInput
3799
+ ? tr('queueNextInput')
3800
+ : tr('send');
3491
3801
  runButton.dataset.action = action;
3492
3802
  runButton.disabled = action === 'cancel'
3493
3803
  ? runCancellationRequested
3494
- : !hasInput;
3804
+ : !hasInput || transitioning || attachmentsLoading;
3495
3805
  runButton.title = label;
3496
3806
  runButton.setAttribute('aria-label', label);
3497
- if (currentRunView?.writebackRetry && hasInput) runButton.disabled = true;
3807
+ if (currentRunView?.writebackRetry && hasInput && action !== 'cancel') runButton.disabled = true;
3498
3808
  }
3499
3809
 
3500
3810
  function handleTaskInputKeydown(event) {
3811
+ if (sessionManager.isTransitioning()) {
3812
+ if (event.key === 'Enter' && !event.isComposing && event.keyCode !== 229) event.preventDefault();
3813
+ return;
3814
+ }
3815
+ if (composerAttachmentController.hasPendingReads() && event.key === 'Enter'
3816
+ && !event.shiftKey && !event.isComposing && event.keyCode !== 229) {
3817
+ event.preventDefault();
3818
+ return;
3819
+ }
3501
3820
  if (handleSlashMenuKeydown(event)) {
3502
3821
  return;
3503
3822
  }
@@ -3525,9 +3844,12 @@
3525
3844
 
3526
3845
  function getSlashCommands() {
3527
3846
  const commands = [
3847
+ { id: 'compact', kind: 'compact', group: 'commands', title: tr('slashCompactTitle'), slug: '/compact',
3848
+ subtitle: tr('slashCompactSubtitle') },
3528
3849
  {
3529
3850
  id: 'install-skill',
3530
3851
  kind: 'installer',
3852
+ group: 'commands',
3531
3853
  title: tr('slashInstallSkillTitle'),
3532
3854
  subtitle: tr('slashInstallSkillSubtitle')
3533
3855
  }
@@ -3542,10 +3864,12 @@
3542
3864
  if (!isCodexOverleafSkillEnabled(id)) {
3543
3865
  continue;
3544
3866
  }
3545
- const title = String(skill?.title || skill?.name || id).trim().slice(0, 80) || id;
3867
+ const title = id === 'reviewer-response' ? tx('Reviewer response', '审稿回复')
3868
+ : String(skill?.title || skill?.name || id).trim().slice(0, 80) || id;
3546
3869
  commands.push({
3547
3870
  id: `skill:${id}`,
3548
3871
  kind: 'codex-overleaf-skill',
3872
+ group: 'overleaf',
3549
3873
  scope: 'codex-overleaf',
3550
3874
  skillId: id,
3551
3875
  title,
@@ -3556,11 +3880,13 @@
3556
3880
  for (const skill of localSkills) {
3557
3881
  if (!isSafeSkillId(skill.id) || !isCodexLocalSkillEnabled(skill.id, skill)) continue;
3558
3882
  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,
3883
+ // Source and duplicate hints lead so a truncated preview cannot hide them.
3884
+ commands.push({ id: `local-skill:${skill.id}`, kind: 'codex-local-skill', group: 'local', scope: 'codex-local', skillId: skill.id,
3560
3885
  title: String(skill.title || skill.name || skill.id).trim().slice(0, 80),
3561
3886
  searchName: String(skill.name || ''),
3562
- subtitle: tr('slashUseLocalSkillSubtitle') + (skill.sourceKind === 'plugin' && skill.sourceLabel ? ' · ' + skill.sourceLabel : '')
3563
- + (sameName ? ' · ' + (skill.sourceHint || skill.id.slice(-8)) : '') });
3887
+ subtitle: [skill.sourceKind === 'plugin' && skill.sourceLabel ? skill.sourceLabel : '',
3888
+ sameName ? skill.sourceHint || skill.id.slice(-8) : '',
3889
+ String(skill.preview || '').trim() || tr('slashUseLocalSkillSubtitle')].filter(Boolean).join(' · ') });
3564
3890
  }
3565
3891
  return commands;
3566
3892
  }
@@ -3607,7 +3933,7 @@
3607
3933
  || command.id.toLowerCase().includes(query)
3608
3934
  || String(command.searchName || '').toLowerCase().includes(query);
3609
3935
  });
3610
- renderSlashMenu(commands);
3936
+ renderSlashMenu(commands, { query });
3611
3937
  return;
3612
3938
  }
3613
3939
  const atTrigger = getAtFileTrigger();
@@ -3705,7 +4031,7 @@
3705
4031
  closeSlashMenu();
3706
4032
  return;
3707
4033
  }
3708
- renderSlashMenu(entries);
4034
+ renderSlashMenu(entries, { query });
3709
4035
  }
3710
4036
 
3711
4037
  // Lazy one-shot load: the tray's file list is usually warm (panel open
@@ -3788,7 +4114,22 @@
3788
4114
  };
3789
4115
  }
3790
4116
 
3791
- function renderSlashMenu(commands = []) {
4117
+ const SLASH_MENU_GROUPS = { commands: 'slashGroupCommands', overleaf: 'slashGroupOverleafSkills', local: 'slashGroupLocalSkills' };
4118
+ const SLASH_MENU_ICONS = { compact: 'compact', installer: 'install', 'codex-overleaf-skill': 'book', 'codex-local-skill': 'terminal' };
4119
+
4120
+ function appendHighlightedText(node, value, query) {
4121
+ const text = String(value || '');
4122
+ const at = query ? text.toLowerCase().indexOf(query) : -1;
4123
+ if (at < 0) {
4124
+ node.append(text);
4125
+ return;
4126
+ }
4127
+ const mark = document.createElement('mark');
4128
+ mark.textContent = text.slice(at, at + query.length);
4129
+ node.append(text.slice(0, at), mark, text.slice(at + query.length));
4130
+ }
4131
+
4132
+ function renderSlashMenu(commands = [], { query = '' } = {}) {
3792
4133
  const menu = panel?.querySelector('[data-slash-menu]');
3793
4134
  if (!menu) {
3794
4135
  return;
@@ -3798,7 +4139,17 @@
3798
4139
  menu.hidden = true;
3799
4140
  return;
3800
4141
  }
4142
+ const usage = commands.some(command => command.kind === 'compact') ? getContextUsageDisplay().usage : null;
4143
+ const percent = usage?.contextWindow > 0 ? usage.usedTokens / usage.contextWindow * 100 : null;
4144
+ let group = '';
3801
4145
  commands.forEach((command, index) => {
4146
+ if (command.group && command.group !== group) {
4147
+ group = command.group;
4148
+ const label = document.createElement('div');
4149
+ label.className = 'codex-slash-menu-group';
4150
+ label.textContent = tr(SLASH_MENU_GROUPS[group]);
4151
+ menu.append(label);
4152
+ }
3802
4153
  const button = document.createElement('button');
3803
4154
  button.type = 'button';
3804
4155
  button.dataset.slashCommand = command.id;
@@ -3810,13 +4161,44 @@
3810
4161
  button.dataset.slashSkillScope = command.scope;
3811
4162
  }
3812
4163
  button.dataset.active = index === 0 ? 'true' : 'false';
4164
+ const icon = document.createElement('span');
4165
+ icon.className = 'codex-slash-menu-icon';
4166
+ icon.innerHTML = Modules.PanelIcons.svg(SLASH_MENU_ICONS[command.kind] || 'file');
4167
+ const text = document.createElement('span');
4168
+ text.className = 'codex-slash-menu-text';
3813
4169
  const title = document.createElement('span');
3814
- title.textContent = command.title;
4170
+ title.className = 'codex-slash-menu-title';
4171
+ appendHighlightedText(title, command.title, query);
4172
+ if (command.slug) {
4173
+ const slug = document.createElement('code');
4174
+ appendHighlightedText(slug, command.slug, query);
4175
+ title.append(slug);
4176
+ }
3815
4177
  const subtitle = document.createElement('small');
3816
4178
  subtitle.textContent = command.subtitle;
3817
- button.append(title, subtitle);
4179
+ subtitle.title = command.subtitle;
4180
+ text.append(title, subtitle);
4181
+ button.append(icon, text);
4182
+ if (command.kind === 'compact' && percent !== null) {
4183
+ const meta = document.createElement('span');
4184
+ meta.className = 'codex-slash-menu-meta';
4185
+ meta.dataset.level = Modules.ContextUsage.level(percent);
4186
+ meta.title = tr('contextUsageTitle');
4187
+ meta.textContent = new Intl.NumberFormat(getLocale() === 'zh' ? 'zh-CN' : 'en-US', { maximumFractionDigits: 0 }).format(percent) + '%';
4188
+ button.append(meta);
4189
+ }
3818
4190
  menu.append(button);
3819
4191
  });
4192
+ const foot = document.createElement('div');
4193
+ foot.className = 'codex-slash-menu-foot';
4194
+ foot.dataset.slashMenuFoot = '';
4195
+ for (const [keys, label] of [[['↑', '↓'], 'slashKeyMove'], [['Enter'], 'slashKeyRun'], [['Esc'], 'slashKeyClose']]) {
4196
+ const hint = document.createElement('span');
4197
+ for (const key of keys) hint.append(Object.assign(document.createElement('kbd'), { textContent: key }));
4198
+ hint.append(tr(label));
4199
+ foot.append(hint);
4200
+ }
4201
+ menu.append(foot);
3820
4202
  renderedSlashCommands = new Map(commands.map(command => [command.id, command]));
3821
4203
  menu.dataset.activeIndex = '0';
3822
4204
  menu.hidden = false;
@@ -3891,10 +4273,16 @@
3891
4273
  });
3892
4274
  const active = buttons[index];
3893
4275
  if (!active || !menu.clientHeight) return;
4276
+ // Wrapping to the first item also reveals its group label.
4277
+ if (index === 0) {
4278
+ menu.scrollTop = 0;
4279
+ return;
4280
+ }
3894
4281
  const viewport = menu.getBoundingClientRect();
3895
4282
  const item = active.getBoundingClientRect();
3896
4283
  const top = viewport.top + menu.clientTop;
3897
- const bottom = top + menu.clientHeight;
4284
+ // The sticky key-hint footer covers the bottom of the scroll area.
4285
+ const bottom = top + menu.clientHeight - (menu.querySelector('[data-slash-menu-foot]')?.offsetHeight || 0);
3898
4286
  // Scroll only this popup; scrollIntoView may move the chat or editor too.
3899
4287
  if (item.height > menu.clientHeight || item.top < top) {
3900
4288
  menu.scrollTop += item.top - top;
@@ -3904,6 +4292,7 @@
3904
4292
  }
3905
4293
 
3906
4294
  function selectSlashCommand(commandId) {
4295
+ if (sessionManager.isTransitioning()) return;
3907
4296
  const command = renderedSlashCommands.get(commandId);
3908
4297
  if (!command) {
3909
4298
  closeSlashMenu();
@@ -3916,6 +4305,11 @@
3916
4305
  applyAtFileSelection(command);
3917
4306
  return;
3918
4307
  }
4308
+ if (command.kind === 'compact') {
4309
+ closeSlashMenu();
4310
+ safeRunTask({ operation: 'compact', consumeCompactDraft: true });
4311
+ return;
4312
+ }
3919
4313
  clearSlashTriggerFromTaskInput();
3920
4314
  closeSlashMenu();
3921
4315
  if (command.kind === 'installer') {
@@ -4191,8 +4585,17 @@
4191
4585
  Bridge: Modules.SharedSessionBridge, StorageDb: Modules.StorageDb,
4192
4586
  getScope: () => ({ accountScopeId: cachedAccountScopeId, projectId: getCurrentProjectId(), generation: spaRouteGeneration }),
4193
4587
  getState: () => state, setState: next => { state = next; },
4194
- isBusy: () => Boolean(readLiveRunViewForSaveStateGuard() || document.querySelector('dialog[open]')),
4195
- normalize: normalizePanelState, render: applyStateToPanel,
4588
+ isDashboard: () => panel?.dataset.view === 'recent-projects',
4589
+ refreshDashboard: refreshDashboardSoon,
4590
+ // An in-flight Accept/Undo keeps its view until it settles; refresh applies afterwards.
4591
+ isBusy: () => Boolean(pendingDraftSave || readLiveRunViewForSaveStateGuard() || trackedChangeInFlight.size
4592
+ || runReviewController?.hasActiveReview?.() || document.querySelector('dialog[open]')),
4593
+ hasLocalDraft: current => Boolean(current.task) && composerDraftEditedHere,
4594
+ normalize: normalizePanelState,
4595
+ render: () => {
4596
+ applyStateToPanel();
4597
+ composerDraftEditedHere = false;
4598
+ },
4196
4599
  notice: error => appendStorageNoticeOnce('shared-history-refresh', tx(
4197
4600
  `Could not refresh shared history: ${error.message}`,
4198
4601
  `共享会话历史刷新失败:${error.message}`
@@ -4292,6 +4695,7 @@
4292
4695
  storageKey = getProjectStorageKey(LEGACY_STORAGE_KEY, window.location.href);
4293
4696
  const reloaded = await loadStoredState();
4294
4697
  state = normalizeLoadedPanelState(reloaded);
4698
+ composerDraftEditedHere = false;
4295
4699
  await getRunReviewController().recoverInterruptedUndoStatuses();
4296
4700
  applyStateToPanel();
4297
4701
  } catch (_error) {
@@ -4354,6 +4758,7 @@
4354
4758
  function leaveActiveProject(newId) {
4355
4759
  const prevId = activeProjectId;
4356
4760
  activeProjectId = newId; // null for non-project URLs (spec §5.7.1)
4761
+ if (prevId !== newId) composerDraftEditedHere = false;
4357
4762
  getComposerDraftPersistence().syncScope();
4358
4763
  if (!prevId || prevId === newId) {
4359
4764
  return;
@@ -4380,7 +4785,15 @@
4380
4785
  }
4381
4786
 
4382
4787
  async function onSpaRouteChange() {
4788
+ // Snapshot the departing draft before hydration replaces state. The detached
4789
+ // writer retains its original account/project and never reads the new view's attachments.
4790
+ if (pendingDraftSave?.projectId === activeProjectId
4791
+ && pendingDraftSave?.accountScopeId === cachedAccountScopeId) {
4792
+ void saveState({ draftScope: pendingDraftSave });
4793
+ }
4794
+ resetDraftSave();
4383
4795
  const generation = ++spaRouteGeneration;
4796
+ activeTurnControl.cancelRecovery();
4384
4797
  const url = new URL(window.location.href);
4385
4798
  const pathname = url.pathname;
4386
4799
  const previousProjectId = activeProjectId;
@@ -4604,7 +5017,7 @@
4604
5017
  operations: [],
4605
5018
  status: tr('modeAsk')
4606
5019
  });
4607
- appendCompletionReport({
5020
+ appendCompletionReport({ conclusionKey: !notes ? 'ask_inspected' : undefined,
4608
5021
  conclusion: notes || tx('This run only inspected and explained; no files were written.', '这轮只做了检查和说明,没有写入文件。'),
4609
5022
  status: tr('modeAsk'),
4610
5023
  notes,
@@ -4646,7 +5059,7 @@
4646
5059
  applyResults,
4647
5060
  status: 'applied with delete plan'
4648
5061
  });
4649
- appendCompletionReport({
5062
+ appendCompletionReport({ conclusionKey: !notes ? 'delete_handled' : undefined,
4650
5063
  conclusion: notes || tx('Changes were written, and delete items were handled after your confirmation.', '已写入修改,并按你的确认处理删除项。'),
4651
5064
  status: 'applied with delete plan',
4652
5065
  notes,
@@ -4669,7 +5082,7 @@
4669
5082
  status: 'applied without deletes',
4670
5083
  deletePlanRejected: true
4671
5084
  });
4672
- appendCompletionReport({
5085
+ appendCompletionReport({ conclusionKey: 'delete_rejected',
4673
5086
  conclusion: tx('Non-delete changes were kept; delete items were not written.', '已保留非删除修改;删除项没有写入。'),
4674
5087
  status: 'applied without deletes',
4675
5088
  notes,
@@ -4699,7 +5112,7 @@
4699
5112
  applyResults: [applied],
4700
5113
  status: result.status || 'completed'
4701
5114
  });
4702
- appendCompletionReport({
5115
+ appendCompletionReport({ conclusionKey: !notes ? 'run_complete' : undefined,
4703
5116
  conclusion: notes || tx('This run is complete.', '这轮任务已完成。'),
4704
5117
  status: result.status || 'completed',
4705
5118
  notes,
@@ -4781,10 +5194,19 @@
4781
5194
  // Thread runProjectId so the page-side dispatcher gates the toggle. Same
4782
5195
  // intent as the pre-flight check above: do not flip Track Changes /
4783
5196
  // Editing in a different project than the run is bound to.
5197
+ const owner = currentRunView;
4784
5198
  const result = await callPageBridge(method, {
4785
5199
  waitMs: 1800,
4786
5200
  runProjectId: currentRunView?.runProjectId || ''
4787
5201
  });
5202
+ throwIfRunCancellationRequested();
5203
+ if (owner && (owner !== currentRunView || owner.runProjectId !== getCurrentProjectId()
5204
+ || owner.runAccountScopeId !== cachedAccountScopeId)) throw createRunCancellationError();
5205
+ if (requireReviewing && (result?.ok || result?.code === 'reviewing_unavailable')) {
5206
+ projectSettingsCoordinator.observeReviewingAvailability(result.ok ? 'available' : 'unavailable',
5207
+ result.ok ? 'reviewing_confirmed' : 'upgrade_modal');
5208
+ syncProbeStatus();
5209
+ }
4788
5210
  if (result?.ok) {
4789
5211
  appendRunEvent({
4790
5212
  title: requireReviewing
@@ -4816,6 +5238,7 @@
4816
5238
  code: result?.code || (requireReviewing ? 'reviewing_not_enabled' : 'editing_not_confirmed'),
4817
5239
  reason: result?.reason || (requireReviewing ? 'Reviewing/Track Changes was not enabled' : 'Editing mode was not confirmed'),
4818
5240
  reviewing: result?.reviewing || null,
5241
+ evidence: result?.evidence, failure: result?.failure,
4819
5242
  requireReviewing
4820
5243
  };
4821
5244
  }
@@ -4830,8 +5253,9 @@
4830
5253
  operation,
4831
5254
  result: {
4832
5255
  ok: false,
4833
- code: blockedByEditing ? 'editing_not_confirmed' : 'reviewing_not_enabled',
4834
- reason: blockedByEditing
5256
+ code: reviewing?.code || (blockedByEditing ? 'editing_not_confirmed' : 'reviewing_not_enabled'),
5257
+ evidence: reviewing?.evidence, failure: reviewing?.failure,
5258
+ reason: reviewing?.code === 'reviewing_unavailable' ? tr('failureReason_reviewing_unavailable_user') : blockedByEditing
4835
5259
  ? tx(
4836
5260
  'Track is off, but Overleaf Editing mode was not verified before writing. Codex did not write this file.',
4837
5261
  '已关闭“留痕”,但写入前没有确认 Overleaf 正在用 Editing 模式。Codex 没有写入这个文件。'
@@ -4885,13 +5309,13 @@
4885
5309
 
4886
5310
  async function refreshProbe(options = {}) {
4887
5311
  const projectId = getCurrentProjectId();
5312
+ const accountScopeId = cachedAccountScopeId;
4888
5313
  const userInitiated = options.userInitiated === true;
4889
5314
  if (userInitiated) {
4890
5315
  setRefreshProbeLoading(true);
4891
5316
  const status = panel?.querySelector('[data-probe-status]');
4892
5317
  if (status) {
4893
- status.textContent = tr('refreshProbeLoading');
4894
- status.dataset.ok = 'false';
5318
+ renderProbeStatusMessage(status, tr('refreshProbeLoading'));
4895
5319
  status.dataset.refreshing = 'true';
4896
5320
  }
4897
5321
  }
@@ -4901,13 +5325,14 @@
4901
5325
  manualOverride: state?.requireReviewing === false
4902
5326
  });
4903
5327
  if (projectId !== getCurrentProjectId()) return null;
5328
+ if (accountScopeId !== cachedAccountScopeId) return null;
5329
+ if (probe.capabilities?.reviewing?.confirmed === true) {
5330
+ projectSettingsCoordinator.observeReviewingAvailability('available', 'reviewing_confirmed');
5331
+ }
4904
5332
  probeStatusSnapshot = { projectId, probe, userInitiated };
4905
5333
  const status = panel?.querySelector('[data-probe-status]');
4906
5334
  if (status) {
4907
- status.textContent = userInitiated
4908
- ? tr('refreshProbeDone', { status: formatProbeStatusBar(probe) })
4909
- : formatProbeStatusBar(probe);
4910
- status.dataset.ok = isProbeReadyForCurrentMode(probe) ? 'true' : 'false';
5335
+ renderProbeStatusLine(status, probe, { refreshed: userInitiated });
4911
5336
  status.dataset.refreshing = 'false';
4912
5337
  }
4913
5338
  if (!options.quiet && !userInitiated) {
@@ -4924,8 +5349,7 @@
4924
5349
  probeStatusSnapshot = { projectId, failed: true };
4925
5350
  const status = panel?.querySelector('[data-probe-status]');
4926
5351
  if (status) {
4927
- status.textContent = tr('refreshProbeFailed');
4928
- status.dataset.ok = 'false';
5352
+ renderProbeStatusMessage(status, tr('refreshProbeFailed'), 'warn');
4929
5353
  status.dataset.refreshing = 'false';
4930
5354
  }
4931
5355
  console.warn('[codex-overleaf] refresh probe failed', error);
@@ -4945,23 +5369,81 @@
4945
5369
  button.dataset.loading = loading ? 'true' : 'false';
4946
5370
  button.disabled = Boolean(loading);
4947
5371
  button.setAttribute('aria-busy', loading ? 'true' : 'false');
5372
+ setElementTitleAndAria('[data-refresh]', tr(loading ? 'refreshProbeLoading' : 'refreshProbe'),
5373
+ tr(loading ? 'refreshProbeLoading' : 'refreshProbeTitle'));
4948
5374
  }
4949
5375
 
4950
5376
  function syncProbeStatus(mode = state?.mode) {
4951
5377
  const status = panel?.querySelector('[data-probe-status]');
4952
5378
  if (!status) return;
4953
5379
  if (status.dataset.refreshing === 'true') {
4954
- status.textContent = tr('refreshProbeLoading');
5380
+ renderProbeStatusMessage(status, tr('refreshProbeLoading'));
4955
5381
  return;
4956
5382
  }
4957
5383
  if (probeStatusSnapshot?.projectId !== getCurrentProjectId()) return;
4958
5384
  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';
5385
+ if (failed) renderProbeStatusMessage(status, tr('refreshProbeFailed'), 'warn');
5386
+ else renderProbeStatusLine(status, probe, { refreshed: userInitiated });
4962
5387
  if (!failed) updateExistingProbeNotice(probe, mode);
4963
5388
  }
4964
5389
 
5390
+ // Keep the read scope visible; a known Track limitation adds its own warning segment.
5391
+ function getProbeScope(probe) {
5392
+ const selection = selectionContextView.capture();
5393
+ const focusFiles = getActiveFocusFiles();
5394
+ // Match submission priority: an edit selection, then explicit focus files,
5395
+ // then reference context, ahead of the editor's implicit current-file hint.
5396
+ if (selection?.mode === 'edit') return { icon: 'file', key: 'probeScopeSelection' };
5397
+ if (focusFiles.some(file => /\.tex$/i.test(file))) return { icon: 'file', key: 'probeScopeFiles' };
5398
+ if (focusFiles.length || selection) return { icon: 'attach', key: 'probeScopeContext' };
5399
+ if (getProbeRunReadiness(probe).editorOk) return { icon: 'file', key: 'probeScopeCurrentFile' };
5400
+ return { icon: 'folder', key: 'probeScopeProject' };
5401
+ }
5402
+
5403
+ function isReviewingKnownUnavailable() {
5404
+ return Modules.Reviewing.knownUnavailable(projectSettingsCoordinator.getReviewingAvailability());
5405
+ }
5406
+
5407
+ function syncReviewingTooltip() {
5408
+ const toggle = panel?.querySelector('[data-require-reviewing]');
5409
+ const title = tr(isReviewingKnownUnavailable() ? 'reviewingUnavailableTooltip' : 'requireReviewingTitle');
5410
+ if (toggle) toggle.title = title;
5411
+ const label = toggle?.closest('.codex-review-toggle');
5412
+ if (label) label.title = title;
5413
+ }
5414
+
5415
+ function renderProbeStatusLine(status, probe, options = {}) {
5416
+ const scope = getProbeScope(probe);
5417
+ const parts = [];
5418
+ if (options.refreshed) parts.push(tr('refreshProbeDone', { status: '' }).trim());
5419
+ const label = document.createElement('span');
5420
+ label.className = 'codex-probe-scope';
5421
+ label.innerHTML = Modules.PanelIcons?.svg?.(scope.icon) || ''; // Static, repository-owned icon path.
5422
+ label.append(tr(scope.key));
5423
+ parts.push(label);
5424
+ if (isExperimentalOtEnabled()) {
5425
+ const separator = document.createElement('span');
5426
+ separator.className = 'codex-probe-sep';
5427
+ separator.textContent = '·';
5428
+ parts.push(separator, 'OT ' + formatOtStatusLabel(getCurrentOtStatus()));
5429
+ }
5430
+ if (state.requireReviewing && isReviewingKnownUnavailable()) {
5431
+ const warning = document.createElement('span');
5432
+ warning.dataset.tone = 'warn';
5433
+ warning.textContent = ' · ' + tr('reviewingUnavailableStatus');
5434
+ parts.push(warning);
5435
+ }
5436
+ syncReviewingTooltip();
5437
+ status.replaceChildren(...parts);
5438
+ delete status.dataset.tone;
5439
+ }
5440
+
5441
+ function renderProbeStatusMessage(status, text, tone = '') {
5442
+ status.replaceChildren(text);
5443
+ if (tone) status.dataset.tone = tone;
5444
+ else delete status.dataset.tone;
5445
+ }
5446
+
4965
5447
  function formatProbeStatusBar(probe, mode = state?.mode) {
4966
5448
  const readiness = getProbeRunReadiness(probe);
4967
5449
  const reviewingOk = readiness.reviewingOk;
@@ -4995,17 +5477,7 @@
4995
5477
  if (!status || status.dataset.refreshing === 'true') {
4996
5478
  return;
4997
5479
  }
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;
5480
+ syncProbeStatus();
5009
5481
  }
5010
5482
 
5011
5483
  function getProbeRunReadiness(probe) {
@@ -5110,8 +5582,8 @@
5110
5582
  ready: true,
5111
5583
  message: manualOverride
5112
5584
  ? tx(
5113
- `Ready: you confirmed Overleaf Track Changes is on. Codex ${readiness.contextLabel}.`,
5114
- `可以运行:你已确认 Overleaf 已开启留痕,Codex ${readiness.contextLabel}。`
5585
+ `Ready: Codex writes without tracked changes and ${readiness.contextLabel}.`,
5586
+ `可以运行:Codex 写入时不使用留痕,并且${readiness.contextLabel}。`
5115
5587
  )
5116
5588
  : tx(
5117
5589
  `Ready: Codex verified Overleaf Reviewing/Track Changes is on and ${readiness.contextLabel}.`,
@@ -5120,27 +5592,20 @@
5120
5592
  };
5121
5593
  }
5122
5594
 
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
-
5595
+ // ensureReviewingBeforeWrite switches Overleaf to Reviewing before the first
5596
+ // write and blocks the write only if that cannot be confirmed.
5133
5597
  return {
5134
- ready: false,
5598
+ ready: true,
5135
5599
  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 指定重点文件。'
5600
+ `Ready: Codex ${readiness.contextLabel} and will switch Overleaf to Reviewing/Track Changes before writing.`,
5601
+ `可以运行:Codex ${readiness.contextLabel},写入前会把 Overleaf 切到 Reviewing/Track Changes。`
5138
5602
  )
5139
5603
  };
5140
5604
  }
5141
5605
 
5142
- async function getRunProjectSnapshot() {
5606
+ async function getRunProjectSnapshot(expectedProjectId = getCurrentProjectId()) {
5143
5607
  const project = await callPageBridge('getProjectSnapshot', {
5608
+ projectId: expectedProjectId,
5144
5609
  force: true,
5145
5610
  maxAgeMs: 0,
5146
5611
  preferLightweight: true,
@@ -5152,11 +5617,12 @@
5152
5617
  zipTimeoutMs: RUN_SNAPSHOT_ZIP_TIMEOUT_MS,
5153
5618
  focusFiles: getActiveFocusFiles()
5154
5619
  });
5155
- const sanitizedProject = sanitizeRunProjectSnapshot(project);
5620
+ const sanitizedProject = sanitizeRunProjectSnapshot(project, expectedProjectId);
5156
5621
  if (sanitizedProject?.capabilities?.fullProjectSnapshot || sanitizedProject?.capabilities?.pathIdentityComplete === false) {
5157
5622
  return sanitizedProject;
5158
5623
  }
5159
5624
  const pageStateProject = await callPageBridge('getProjectSnapshot', {
5625
+ projectId: expectedProjectId,
5160
5626
  force: true,
5161
5627
  maxAgeMs: 0,
5162
5628
  preferLightweight: true,
@@ -5166,17 +5632,21 @@
5166
5632
  includeBinaryFiles: false,
5167
5633
  focusFiles: getActiveFocusFiles()
5168
5634
  });
5169
- const sanitizedPageStateProject = sanitizeRunProjectSnapshot(pageStateProject);
5635
+ const sanitizedPageStateProject = sanitizeRunProjectSnapshot(pageStateProject, expectedProjectId);
5170
5636
  if (sanitizedPageStateProject?.capabilities?.fullProjectSnapshot) {
5171
5637
  return sanitizedPageStateProject;
5172
5638
  }
5173
5639
  return sanitizedProject;
5174
5640
  }
5175
5641
 
5176
- function sanitizeRunProjectSnapshot(project) {
5642
+ function sanitizeRunProjectSnapshot(project, expectedProjectId = project?.id) {
5177
5643
  if (!project || typeof project !== 'object') return project;
5644
+ if (project.id !== expectedProjectId) return { ...project, files: [], capabilities: { ...project.capabilities,
5645
+ fullProjectSnapshot: false, skipped: [{ path: '', reason: 'document_identity_mismatch' }] } };
5178
5646
  const normalize = Modules.ProjectFiles.normalizeSafeProjectPath;
5179
- const files = Array.isArray(project.files) ? project.files : [];
5647
+ const files = Array.isArray(project.files) ? project.files.map(file => file?.documentIdentity === undefined ? file
5648
+ : { ...file, documentIdentity: snapshotDocumentIdentity(file.documentIdentity, file.path, project.id) }) : [];
5649
+ project = { ...project, files };
5180
5650
  const counts = new Map();
5181
5651
  for (const file of files) {
5182
5652
  const path = normalize(file?.path);
@@ -5213,12 +5683,16 @@
5213
5683
  // --- Warm Mirror Controller ---
5214
5684
 
5215
5685
  async function getMirrorFreshness() {
5686
+ const projectId = getCurrentProjectId(), accountScopeId = cachedAccountScopeId;
5687
+ if (await writebackOrchestrator.refreshMirrorReuseFence(projectId, accountScopeId)) return null;
5688
+ if (getCurrentProjectId() !== projectId || cachedAccountScopeId !== accountScopeId) return null;
5216
5689
  try {
5217
5690
  const response = await sendBackgroundNative({
5218
5691
  method: 'mirror.status',
5219
- params: { projectId: getCurrentProjectId() }
5692
+ params: { projectId }
5220
5693
  });
5221
- if (response?.ok) {
5694
+ const fence = await writebackOrchestrator.refreshMirrorReuseFence(projectId, accountScopeId);
5695
+ if (response?.ok && !fence && getCurrentProjectId() === projectId && cachedAccountScopeId === accountScopeId) {
5222
5696
  return response.result;
5223
5697
  }
5224
5698
  } catch (error) { /* fall through */ }
@@ -5249,7 +5723,7 @@
5249
5723
  }
5250
5724
 
5251
5725
  const mirrorStatus = await getMirrorFreshness();
5252
- const mirrorHealth = Modules.MirrorHealth.classifyMirrorHealth(mirrorStatus);
5726
+ const mirrorHealth = Modules.MirrorHealth.classifyMirrorHealth(mirrorStatus || {});
5253
5727
  if (!mirrorStatus?.exists || !mirrorHealth.reusable) {
5254
5728
  return { useExistingMirror: false, reason: 'mirror_not_fresh', mirrorStatus };
5255
5729
  }
@@ -5278,17 +5752,33 @@
5278
5752
  return candidates
5279
5753
  .filter(file =>
5280
5754
  typeof file.content === 'string' &&
5281
- Modules.ProjectFiles.isUsableProjectFileContent(file.content) &&
5755
+ (Modules.ProjectFiles.isUsableProjectFileContent(file.content) || file.content === '') &&
5282
5756
  file.path &&
5283
5757
  !seen.has(file.path) &&
5284
5758
  seen.add(file.path)
5285
5759
  )
5286
5760
  .map(file => ({
5287
5761
  path: file.path,
5288
- content: file.content
5762
+ content: file.content,
5763
+ kind: 'text',
5764
+ source: file.source,
5765
+ ...(file.documentIdentity !== undefined
5766
+ ? { documentIdentity: snapshotDocumentIdentity(file.documentIdentity, file.path, project.id) } : {})
5289
5767
  }));
5290
5768
  }
5291
5769
 
5770
+ function snapshotDocumentIdentity(identity, path, projectId) {
5771
+ // Preserve invalid evidence as invalid, never silently downgrade to the
5772
+ // missing-evidence fallback. Native independently hashes the content.
5773
+ if (!identity || identity.version !== 1 || identity.source !== 'cm-ranges-v1'
5774
+ || identity.path !== path || identity.projectId !== projectId
5775
+ || typeof identity.documentId !== 'string' || !/^[a-f0-9]{24}$/.test(identity.documentId)
5776
+ || typeof identity.targetDocumentId !== 'string' || !/^[a-f0-9]{24}$/.test(identity.targetDocumentId)
5777
+ || typeof identity.contentSha256 !== 'string' || !/^[a-f0-9]{64}$/.test(identity.contentSha256)) return null;
5778
+ return { version: 1, source: 'cm-ranges-v1', projectId, path,
5779
+ documentId: identity.documentId, targetDocumentId: identity.targetDocumentId, contentSha256: identity.contentSha256 };
5780
+ }
5781
+
5292
5782
  function normalizeSnapshotPath(path) {
5293
5783
  // Snapshot/overlay/focus arrays already carry canonical names from file chips.
5294
5784
  return Modules.ProjectFiles.normalizeSafeProjectPath(path);
@@ -5521,6 +6011,7 @@
5521
6011
  codexLocalSkillEnabled: prefs.codexLocalSkillEnabled || {},
5522
6012
  experimentalOtByProject: prefs.experimentalOtByProject || {},
5523
6013
  customInstructionsByProject: prefs.customInstructionsByProject || {},
6014
+ reviewingAvailabilityByProject: prefs.reviewingAvailabilityByProject || {},
5524
6015
  governanceRulesByProject: normalizeGovernanceRulesByProject(prefs.governanceRulesByProject),
5525
6016
  panelWidth: prefs.panelWidth || PANEL_DEFAULT_WIDTH,
5526
6017
  sessions: sessions.map(session => ({
@@ -5531,6 +6022,7 @@
5531
6022
  focusFiles: session.focusFiles || [], selectionContext: session.selectionContext?.projectId === projectId ? session.selectionContext : null,
5532
6023
  projectReferenceFiles: session.projectReferenceFiles || [],
5533
6024
  codexThreadId: session.codexThreadId || '',
6025
+ contextUsage: session.contextUsage || null,
5534
6026
  forkedFromSessionId: session.forkedFromSessionId || '',
5535
6027
  forkedFromRunId: session.forkedFromRunId || '',
5536
6028
  createdAt: session.createdAt,
@@ -5554,6 +6046,8 @@
5554
6046
  if (!accountScopeId || isScopedPersistenceIdentityFailure(error)) {
5555
6047
  throw error;
5556
6048
  }
6049
+ const fallbackProjectId = getCurrentProjectId();
6050
+ const fallbackGeneration = spaRouteGeneration;
5557
6051
  const fallbackKey = buildScopedFallbackStorageKey(storageKey, accountScopeId);
5558
6052
  const stored = await chrome.storage.local.get([fallbackKey]);
5559
6053
  const fallback = stored[fallbackKey] || {};
@@ -5561,6 +6055,10 @@
5561
6055
  fallback?.__codexOverleafCompactFallback === true
5562
6056
  && fallback.__codexOverleafFallbackAccountScopeId === accountScopeId
5563
6057
  ) {
6058
+ if (accountScopeId === cachedAccountScopeId && fallbackProjectId === getCurrentProjectId()
6059
+ && fallbackGeneration === spaRouteGeneration) {
6060
+ Modules.StorageMigration.adoptProjectPreferenceBaseline(fallback, accountScopeId, fallbackProjectId);
6061
+ }
5564
6062
  return {
5565
6063
  mode: fallback.mode || '', providerId: fallback.providerId || 'builtin',
5566
6064
  model: fallback.model || '',
@@ -5576,6 +6074,7 @@
5576
6074
  codexLocalSkillEnabled: fallback.codexLocalSkillEnabled || {},
5577
6075
  experimentalOtByProject: fallback.experimentalOtByProject || {},
5578
6076
  customInstructionsByProject: fallback.customInstructionsByProject || {},
6077
+ reviewingAvailabilityByProject: fallback.reviewingAvailabilityByProject || {},
5579
6078
  governanceRulesByProject: normalizeGovernanceRulesByProject(fallback.governanceRulesByProject),
5580
6079
  panelWidth: fallback.panelWidth || PANEL_DEFAULT_WIDTH,
5581
6080
  sessions: [],
@@ -5610,9 +6109,8 @@
5610
6109
  // Settlement owns a run after navigation away from its project. Ordinary
5611
6110
  // view saves must not write that run into the current route's project.
5612
6111
  // An explicit override is reserved for callers that own a detached scope.
5613
- const projectIdOverride = options && typeof options.projectIdOverride === 'string'
5614
- ? options.projectIdOverride
5615
- : '';
6112
+ const projectIdOverride = options?.draftScope?.projectId || (options && typeof options.projectIdOverride === 'string'
6113
+ ? options.projectIdOverride : '');
5616
6114
  const urlProjectId = getCurrentProjectId();
5617
6115
  const projectId = projectIdOverride || urlProjectId;
5618
6116
  if (!projectId) {
@@ -5630,6 +6128,8 @@
5630
6128
  ));
5631
6129
  if (options?.requireDurable || options?.reviewRunIds?.length) throw new Error('Persistence project changed.'); return;
5632
6130
  }
6131
+ const savedDraft = !projectIdOverride && pendingDraftSave?.scope === getComposerDraftScope()
6132
+ ? pendingDraftSave : null;
5633
6133
  const compactState = prepareStateForStorage(state, { onAggressive: notifyAggressiveCompactionOnce });
5634
6134
  compactState.autoRecompile = state.autoRecompile;
5635
6135
  compactState.loadCodexLocalSkills = state.loadCodexLocalSkills !== false;
@@ -5638,10 +6138,24 @@
5638
6138
  compactState.codexLocalSkillEnabled = getCodexLocalSkillEnabled();
5639
6139
  compactState.experimentalOtByProject = state.experimentalOtByProject;
5640
6140
  compactState.customInstructionsByProject = state.customInstructionsByProject;
6141
+ compactState.reviewingAvailabilityByProject = state.reviewingAvailabilityByProject;
5641
6142
  compactState.governanceRulesByProject = state.governanceRulesByProject;
6143
+ const projectPreferenceBaseline = Migration.getProjectPreferenceBaseline(
6144
+ options?.draftScope?.accountScopeId || cachedAccountScopeId, projectId);
5642
6145
  const stateSnapshot = typeof structuredClone === 'function'
5643
6146
  ? structuredClone(state)
5644
6147
  : JSON.parse(JSON.stringify(state));
6148
+ const projectPreferenceWrite = Migration.captureProjectPreferenceWrite({
6149
+ state: stateSnapshot, baseline: projectPreferenceBaseline,
6150
+ accountScopeId: options?.draftScope?.accountScopeId || cachedAccountScopeId, projectId,
6151
+ normalizeExperimentalOtByProject, normalizeGovernanceRulesByProject, normalizeCustomInstructionsByProject
6152
+ });
6153
+ // Keep consumption provenance on the live run owner across ordinary
6154
+ // session normalization; it is not a composer edit or a queued input.
6155
+ const consumedSession = liveRunView?.draftConsumption && liveRunView.runProjectId === projectId
6156
+ && liveRunView.runAccountScopeId === cachedAccountScopeId
6157
+ ? stateSnapshot.sessions?.find(session => session.id === liveRunView.sessionId) : null;
6158
+ if (consumedSession?.task === '') consumedSession.draftConsumption = { ...liveRunView.draftConsumption };
5645
6159
  const compactStateSnapshot = typeof structuredClone === 'function'
5646
6160
  ? structuredClone(compactState)
5647
6161
  : JSON.parse(JSON.stringify(compactState));
@@ -5660,9 +6174,33 @@
5660
6174
  normalizeExperimentalOtByProject,
5661
6175
  normalizeGovernanceRulesByProject,
5662
6176
  normalizeCustomInstructionsByProject,
6177
+ projectPreferenceBaseline,
6178
+ projectPreferenceWrite,
6179
+ applyProjectPreferences: (prefs, captured, fields) => {
6180
+ if (retentionScope.accountScopeId !== cachedAccountScopeId || retentionScope.projectId !== getCurrentProjectId()
6181
+ || retentionScope.generation !== spaRouteGeneration) return false;
6182
+ const adopted = [];
6183
+ for (const field of fields) {
6184
+ if (JSON.stringify(state[field]?.[projectId]) !== JSON.stringify(captured[field]?.[projectId])) continue;
6185
+ if (JSON.stringify(state[field]?.[projectId]) === JSON.stringify(prefs[field]?.[projectId])) continue;
6186
+ state[field] = { ...state[field], ...prefs[field] };
6187
+ if (!Object.hasOwn(prefs[field] || {}, projectId)) delete state[field][projectId];
6188
+ adopted.push(field);
6189
+ }
6190
+ if (adopted.includes('customInstructionsByProject')) syncCustomInstructionsEditorForProject(projectId);
6191
+ if (adopted.includes('governanceRulesByProject')) syncProjectSettingsEditorForProject();
6192
+ if (adopted.includes('experimentalOtByProject')) syncExperimentalOtToggleForProject(projectId);
6193
+ if (adopted.includes('reviewingAvailabilityByProject')) {
6194
+ syncReviewingTooltip();
6195
+ syncProbeStatus();
6196
+ }
6197
+ return true;
6198
+ },
5663
6199
  persistenceContext
5664
6200
  });
5665
- await getComposerDraftPersistence().save({ accountScopeId: persistenceContext.scope.accountScopeId, projectId });
6201
+ if (!options?.draftScope) {
6202
+ await getComposerDraftPersistence().save({ accountScopeId: persistenceContext.scope.accountScopeId, projectId });
6203
+ }
5666
6204
  };
5667
6205
 
5668
6206
  const persistenceCoordinator = getScopedPersistenceCoordinator();
@@ -5671,17 +6209,16 @@
5671
6209
  code: 'scoped_persistence_unavailable'
5672
6210
  });
5673
6211
  } else {
5674
- const committed = await persistenceCoordinator.commit(
5675
- projectId,
5676
- {
6212
+ const commitOptions = {
5677
6213
  detached: Boolean(projectIdOverride),
5678
6214
  rebase: true,
5679
6215
  queueMutation: options?.queueMutation,
5680
6216
  queueMutations: options?.queueMutations,
5681
6217
  sessionTombstones: options?.deletedSessionIds
5682
- },
5683
- persistScopedState
5684
- );
6218
+ };
6219
+ const committed = options?.draftScope
6220
+ ? await persistenceCoordinator.commitDetached(projectId, options.draftScope.accountScopeId, commitOptions, persistScopedState)
6221
+ : await persistenceCoordinator.commit(projectId, commitOptions, persistScopedState);
5685
6222
  if (!committed.ok && (
5686
6223
  options?.requireDurable || options?.reviewRunIds?.length || options?.recoveryRuns?.length || options?.queueMutation
5687
6224
  || options?.queueMutations?.length
@@ -5692,6 +6229,16 @@
5692
6229
  });
5693
6230
  }
5694
6231
  if (committed.ok) {
6232
+ if (savedDraft && pendingDraftSave === savedDraft) {
6233
+ const capturedTask = stateSnapshot.sessions.find(item => item.id === stateSnapshot.activeSessionId)?.task;
6234
+ const storedTask = StorageDb.buildSessionRecord({ task: capturedTask }).task;
6235
+ if (!(written || []).some(session => session.id === stateSnapshot.activeSessionId && session.task === storedTask)) {
6236
+ throw Object.assign(new Error(tx('The latest draft could not be saved. It is still available in this page.',
6237
+ '最新草稿未能保存,内容仍保留在当前页面。')), { code: 'draft_state_not_persisted' });
6238
+ }
6239
+ // A newer input revision keeps its own timer, even if this save finishes later.
6240
+ resetDraftSave();
6241
+ }
5695
6242
  const retentionUpdates = StorageDb.projectRetentionUpdates?.({ liveState: state, capturedState: stateSnapshot, written,
5696
6243
  capturedScope: retentionScope, currentScope: { accountScopeId: typeof cachedAccountScopeId === 'string' ? cachedAccountScopeId : '',
5697
6244
  projectId: getCurrentProjectId(), generation: typeof spaRouteGeneration === 'number' ? spaRouteGeneration : null } }) || [];
@@ -5707,6 +6254,8 @@
5707
6254
  }
5708
6255
  } catch (error) {
5709
6256
  if (options?.requireDurable) throw error;
6257
+ // A departing draft must never fall back using the destination page's state/scope.
6258
+ if (options?.draftScope) return;
5710
6259
  if (error?.code === 'recovery_scope_changed') return;
5711
6260
  if (
5712
6261
  error?.code === 'account_scope_unavailable' &&
@@ -5824,6 +6373,65 @@
5824
6373
  }, delayMs);
5825
6374
  }
5826
6375
 
6376
+ function getComposerDraftScope() {
6377
+ return JSON.stringify([cachedAccountScopeId, getCurrentProjectId(), spaRouteGeneration, state.activeSessionId]);
6378
+ }
6379
+
6380
+ function resetDraftSave() {
6381
+ if (draftSaveTimer) clearTimeout(draftSaveTimer);
6382
+ draftSaveTimer = null;
6383
+ pendingDraftSave = null;
6384
+ }
6385
+
6386
+ function captureComposerDraft() {
6387
+ const input = panel?.querySelector('[data-task]');
6388
+ if (!input || !state.activeSessionId) return;
6389
+ const next = updateActiveSessionDraft(state, input.value);
6390
+ if (next === state) return;
6391
+ if (next.task !== state.task) {
6392
+ composerDraftEditedHere = true;
6393
+ if (currentRunView?.sessionId === state.activeSessionId) delete currentRunView.draftConsumption;
6394
+ }
6395
+ state = next;
6396
+ const now = Date.now(), scope = getComposerDraftScope();
6397
+ pendingDraftSave = {
6398
+ scope,
6399
+ projectId: getCurrentProjectId(),
6400
+ accountScopeId: cachedAccountScopeId,
6401
+ firstAt: pendingDraftSave?.scope === scope ? pendingDraftSave.firstAt : now
6402
+ };
6403
+ if (draftSaveTimer) clearTimeout(draftSaveTimer);
6404
+ const delay = Math.max(0, Math.min(DRAFT_SAVE_DELAY_MS, pendingDraftSave.firstAt + DRAFT_SAVE_MAX_WAIT_MS - now));
6405
+ draftSaveTimer = setTimeout(() => {
6406
+ draftSaveTimer = null;
6407
+ if (pendingDraftSave?.scope !== getComposerDraftScope()) { resetDraftSave(); return; }
6408
+ pendingDraftSave.firstAt = Date.now();
6409
+ // Join the existing writer immediately; never reset its timer on every key.
6410
+ saveStateSoon(0);
6411
+ }, delay);
6412
+ }
6413
+
6414
+ async function flushComposerDraft({ requireDurable = false } = {}) {
6415
+ if (!pendingDraftSave) return;
6416
+ const scope = getComposerDraftScope();
6417
+ if (pendingDraftSave.scope !== scope) { resetDraftSave(); return; }
6418
+ if (draftSaveTimer) clearTimeout(draftSaveTimer);
6419
+ draftSaveTimer = null;
6420
+ pendingDraftSave.firstAt = Date.now();
6421
+ await flushQueuedSaveState(requireDurable ? { requireDurable: true } : null);
6422
+ if (requireDurable && scope !== getComposerDraftScope()) {
6423
+ throw new Error(tx('The active session changed while saving the draft. Try again.',
6424
+ '保存草稿时活动会话已改变,请重试。'));
6425
+ }
6426
+ }
6427
+
6428
+ function flushComposerDraftOnLeave() {
6429
+ // Lifecycle events cannot await completion; periodic saves remain the primary protection.
6430
+ flushComposerDraft().catch(error => appendStorageNoticeOnce('save-failed', tx(
6431
+ `Failed to save session state: ${localizeVisibleReason(error.message)}`,
6432
+ `保存会话状态失败:${localizeVisibleReason(error.message)}`)));
6433
+ }
6434
+
5827
6435
  function runQueuedSaveState() {
5828
6436
  if (saveStateInFlightPromise) {
5829
6437
  saveStateRunAfterFlight = true;
@@ -5924,13 +6532,11 @@
5924
6532
  async function persistPanelInputs(event) {
5925
6533
  const globalWrite = getGlobalPreferences().handleInput(event);
5926
6534
  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();
6535
+ // Draft edits retain run/event identity. Composition updates also capture the
6536
+ // current text; compositionend captures its committed value if no final input fires.
6537
+ if (event?.target?.matches?.('[data-task]')) {
6538
+ captureComposerDraft();
6539
+ if (event.type === 'change') await flushComposerDraft();
5934
6540
  return;
5935
6541
  }
5936
6542
  if (event?.target?.matches?.('[data-speed]')) {
@@ -5942,8 +6548,18 @@
5942
6548
  }
5943
6549
  renderSpeedOptions(getRenderedModelEntries());
5944
6550
  readPanelInputs();
6551
+ // Only a user re-enabling Track requests another capability check. The
6552
+ // preceding input event and programmatic session renders must not reset it.
6553
+ if (event?.type === 'change' && event.isTrusted === true
6554
+ && event.target?.matches?.('[data-require-reviewing]') && event.target.checked
6555
+ && isReviewingKnownUnavailable()) {
6556
+ projectSettingsCoordinator.observeReviewingAvailability('unknown', 'user_retry');
6557
+ syncReviewingTooltip();
6558
+ syncProbeStatus();
6559
+ }
5945
6560
  renderModelConfigChoices();
5946
6561
  updateModelDisplay();
6562
+ renderContextUsage();
5947
6563
  // Save feedback is per-card now (settingsPanel flashSaved); the global
5948
6564
  // header chip and its Saving/Saved lifecycle are gone.
5949
6565
  await saveState();
@@ -6037,9 +6653,17 @@
6037
6653
  if (!composerDraftPersistence) composerDraftPersistence = Modules.ComposerAttachmentDraft.createController({
6038
6654
  getScope: () => ({ accountScopeId: cachedAccountScopeId, projectId: getCurrentProjectId() }),
6039
6655
  getAttachments: () => composerAttachmentController.serializePersistableAttachments(),
6040
- setAttachments: value => { state.composerAttachments = value; },
6656
+ getOmittedAttachmentNames: () => composerAttachmentController.getOmittedAttachmentNames(),
6657
+ setAttachments: value => {
6658
+ state.composerAttachments = value;
6659
+ syncComposerSendAvailability();
6660
+ },
6041
6661
  clear: () => composerAttachmentController.clear({ notify: false }),
6042
6662
  restore: value => composerAttachmentController.restorePersistedAttachments(value),
6663
+ onRestoreOmittedAttachments: ({ count, names }) => appendPlainLog(tx(
6664
+ `${count} attachment${count === 1 ? '' : 's'} could not be saved in the draft. Reattach before sending: ${names.join(', ')}.`,
6665
+ `${count} 个附件未能保存到草稿中,请在发送前重新添加:${names.join('、')}。`
6666
+ )),
6043
6667
  read: scope => Modules.StorageDb.readComposerAttachmentDraft(scope),
6044
6668
  write: (scope, value) => Modules.StorageDb.writeComposerAttachmentDraft(scope, value),
6045
6669
  scheduleSave: () => saveStateSoon()
@@ -6073,6 +6697,7 @@
6073
6697
  if (findSessionById(submission.sessionId)?.task !== submission.task) return false;
6074
6698
  if (state.activeSessionId !== submission.sessionId) {
6075
6699
  updateSessionById(submission.sessionId, { task: '' });
6700
+ recordSubmittedDraftConsumption(submission.sessionId, submission.task);
6076
6701
  saveStateSoon();
6077
6702
  return false;
6078
6703
  }
@@ -6086,6 +6711,7 @@
6086
6711
  if (taskInput) {
6087
6712
  taskInput.value = '';
6088
6713
  }
6714
+ composerDraftEditedHere = false;
6089
6715
  autosizeTaskTextarea();
6090
6716
  if (!keepAttachments) {
6091
6717
  composerAttachmentController.clear();
@@ -6094,10 +6720,20 @@
6094
6720
  renderComposerSkillInvocation();
6095
6721
  selectionContextView.clear();
6096
6722
  state = updateActiveSession(state, { task: '' });
6723
+ if (submission) recordSubmittedDraftConsumption(submission.sessionId, submission.task);
6724
+ else if (currentRunView?.sessionId === state.activeSessionId) delete currentRunView.draftConsumption;
6097
6725
  saveStateSoon();
6098
6726
  return true;
6099
6727
  }
6100
6728
 
6729
+ function recordSubmittedDraftConsumption(sessionId, task) {
6730
+ if (task && currentRunView?.sessionId === sessionId
6731
+ && currentRunView.runProjectId === getCurrentProjectId()
6732
+ && currentRunView.runAccountScopeId === cachedAccountScopeId) {
6733
+ currentRunView.draftConsumption = { runId: currentRunView.recordId, task };
6734
+ }
6735
+ }
6736
+
6101
6737
  function restoreRejectedLocalSkillSubmission(submission, checkpoint) {
6102
6738
  const input = panel?.querySelector('[data-task]');
6103
6739
  if (!checkpoint || !input || submission.accountScopeId !== cachedAccountScopeId
@@ -6115,6 +6751,7 @@
6115
6751
  state = updateActiveSession(state, { task: submission.task, selectionContext: submission.selectionContext });
6116
6752
  renderComposerSkillInvocation();
6117
6753
  selectionContextView.render();
6754
+ syncProbeStatus();
6118
6755
  autosizeTaskTextarea();
6119
6756
  syncComposerSendAvailability();
6120
6757
  saveStateSoon();
@@ -6122,11 +6759,15 @@
6122
6759
  }
6123
6760
 
6124
6761
  function applyStateToPanel(options = {}) {
6762
+ if (pendingDraftSave && pendingDraftSave.scope !== getComposerDraftScope()) resetDraftSave();
6763
+ contextUsageView?.close();
6125
6764
  selectionContextView.render();
6126
6765
  writingStyleSettings.sync();
6127
6766
  applyPanelTheme(getThemePreference());
6128
6767
  if ((state.providerId || 'builtin') === 'builtin' && !modelSelectHasOption(state.model)) {
6129
- renderModelOptions(getModelCatalog().FALLBACK_MODELS, state.model);
6768
+ renderModelOptions(getModelCatalog().FALLBACK_MODELS, state.model, {
6769
+ provisionalSelected: getModelDiscovery().provisional === true
6770
+ });
6130
6771
  }
6131
6772
  panel.querySelector('[data-model]').value = state.model;
6132
6773
  const renderedModels = getRenderedModelEntries();
@@ -6144,6 +6785,7 @@
6144
6785
  autosizeTaskTextarea();
6145
6786
  syncComposerSendAvailability();
6146
6787
  panel.querySelector('[data-require-reviewing]').checked = state.requireReviewing;
6788
+ syncReviewingTooltip();
6147
6789
  const recompileCheckbox = panel?.querySelector('[data-auto-recompile]');
6148
6790
  if (recompileCheckbox) {
6149
6791
  recompileCheckbox.checked = state.autoRecompile !== false;
@@ -6198,6 +6840,47 @@
6198
6840
  if (record) configureWritebackButton(root, record, { running });
6199
6841
  });
6200
6842
  renderPendingInputs();
6843
+ renderContextUsage();
6844
+ }
6845
+
6846
+ function renderContextUsage() {
6847
+ if (!contextUsageView || !state) return;
6848
+ contextUsageView.setState(getContextUsageDisplay());
6849
+ }
6850
+
6851
+ function getContextUsageDisplay() {
6852
+ const session = getActiveSession(state);
6853
+ const run = currentRunView?.sessionId === session?.id
6854
+ ? findRunRecord(currentRunView.recordId, currentRunView.sessionId) : null;
6855
+ const selection = providerSettingsCoordinator.getRunSelection(state.providerId);
6856
+ const runningSelection = run?.status === 'running' && run.model === state.model
6857
+ && (run.providerId || 'builtin') === (state.providerId || 'builtin');
6858
+ const scope = {
6859
+ threadId: session?.codexThreadId || '',
6860
+ providerId: state.providerId || 'builtin',
6861
+ providerRevision: runningSelection ? Number(run.providerRevision || 0)
6862
+ : selection?.providerRevision ?? ((state.providerId || 'builtin') === 'builtin' ? 0 : null),
6863
+ model: state.model || ''
6864
+ };
6865
+ // The composer can select the next turn's model while this turn runs.
6866
+ // Only show statistics produced by the selected model and provider.
6867
+ const matchesRun = run && run.model === scope.model
6868
+ && (run.providerId || 'builtin') === scope.providerId
6869
+ && Number(run.providerRevision || 0) === scope.providerRevision;
6870
+ // Starting another turn must not revive statistics from before an automatic
6871
+ // compaction. Retain the watermark across this thread's saved normal runs.
6872
+ const compactedAt = (session?.runs || []).reduce((latest, candidate) => candidate && !candidate.forkSnapshot
6873
+ && candidate.operation !== 'compact' && scope.threadId && candidate.codexThreadId === scope.threadId
6874
+ ? Math.max(latest, Modules.RunActivityModel.getCompactedAt(candidate)) : latest, 0);
6875
+ const { usage, pending, compacted } = Modules.ContextUsage.status(session, { ...scope, compactedAt });
6876
+ const compacting = Boolean(matchesRun && Modules.RunActivityModel.project(run).compacting);
6877
+ return { usage, pending, compacted, compacting, compactingKind: compacting ? (run.operation === 'compact' ? 'manual' : 'auto') : '' };
6878
+ }
6879
+
6880
+ function getCompactAvailability() {
6881
+ if (sessionManager.isTransitioning() || updateIdleClient?.isApplying() || currentRunView || runSubmissionPending) return { ok: false, reason: 'compactBusy' };
6882
+ if (!getActiveSession(state)?.codexThreadId) return { ok: false, reason: 'compactNoThread' };
6883
+ return { ok: true };
6201
6884
  }
6202
6885
 
6203
6886
  function prepareRetryReplacement(run, task) {
@@ -6231,6 +6914,7 @@
6231
6914
  sessionId = state.activeSessionId,
6232
6915
  sessionContext = runController.captureSessionContext(state),
6233
6916
  codexThreadId = '',
6917
+ operation,
6234
6918
  task,
6235
6919
  mode,
6236
6920
  model,
@@ -6247,6 +6931,7 @@
6247
6931
  resetAutoFollow();
6248
6932
  const record = {
6249
6933
  id: createRunId(),
6934
+ ...(operation === 'compact' ? { operation } : {}),
6250
6935
  task,
6251
6936
  mode,
6252
6937
  ...providerSettingsCoordinator.getProviderSnapshot(executionSnapshot?.providerId || state?.providerId),
@@ -6276,12 +6961,20 @@
6276
6961
  runAccountScopeId: cachedAccountScopeId || ''
6277
6962
  };
6278
6963
  const active = findSessionById(sessionId);
6279
- const titlePatch = active?.titleSource !== 'manual'
6964
+ const titlePatch = operation !== 'compact' && active?.titleSource !== 'manual'
6280
6965
  ? {
6281
6966
  title: deriveSessionTitle([], task),
6282
6967
  titleSource: 'auto'
6283
6968
  }
6284
6969
  : {};
6970
+ // Establish the in-memory owner before publishing or rendering the record.
6971
+ // If presentation throws, runTask still has the identity needed to settle it.
6972
+ const view = currentRunView = {
6973
+ operation, sessionId, sessionContext, codexThreadId, recordId: record.id,
6974
+ runProjectId: record.runProjectId, runAccountScopeId: record.runAccountScopeId,
6975
+ executionSnapshot: record.executionSnapshot, startedAt: Date.now(), queueItemId,
6976
+ nativeRequestId: '', activeTurn: null
6977
+ };
6285
6978
  updateSessionById(sessionId, {
6286
6979
  runs: runController.replaceRunForRetry(active?.runs, replaceRunId, record),
6287
6980
  ...titlePatch
@@ -6290,6 +6983,16 @@
6290
6983
 
6291
6984
  const log = panel.querySelector('[data-log]');
6292
6985
  const root = renderRunCard(record);
6986
+ Object.assign(view, {
6987
+ root,
6988
+ runProcess: root.querySelector('[data-run-process]'),
6989
+ processLabel: root.querySelector('[data-run-process-summary]'),
6990
+ events: root.querySelector('[data-run-events]'),
6991
+ report: root.querySelector('[data-run-report]'),
6992
+ status: root.querySelector('[data-run-status]'),
6993
+ // Seed references before warm-mirror resolution produces its snapshot.
6994
+ projectFiles: getCurrentProjectReferenceFiles()
6995
+ });
6293
6996
  if (state.activeSessionId === sessionId) {
6294
6997
  removeEmptyRunsMessage(log);
6295
6998
  if (replaceRunId) log.querySelector(`[data-run-id="${cssEscape(replaceRunId)}"]`)?.remove();
@@ -6300,32 +7003,7 @@
6300
7003
  renderSessionList();
6301
7004
  applySessionLabel();
6302
7005
 
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
- };
7006
+ return view;
6329
7007
  }
6330
7008
 
6331
7009
  function touchSessionForTerminalRun(sessionId, finishedAt) {
@@ -6388,6 +7066,7 @@
6388
7066
  ].filter(Boolean).join(' · ');
6389
7067
  collapseRunProcess(visibleView, statusText);
6390
7068
  }
7069
+ renderContextUsage();
6391
7070
  }
6392
7071
 
6393
7072
 
@@ -6402,6 +7081,11 @@
6402
7081
  }
6403
7082
 
6404
7083
  const owner = currentRunView && findRunRecord(currentRunView.recordId, currentRunView.sessionId);
7084
+ if (storeContextUsage(event, owner, findSessionById(currentRunView?.sessionId), journalSeq)) {
7085
+ scheduleRunStateSave('stream');
7086
+ renderContextUsage();
7087
+ return;
7088
+ }
6405
7089
  if (owner && Modules.SubagentActivityModel.ingest(owner, event, {
6406
7090
  locale: getLocale(), sanitize: sanitizeAssistantVisibleText, nativeEventSeq: journalSeq
6407
7091
  })) {
@@ -6430,6 +7114,20 @@
6430
7114
  replaceText: activity.replaceText,
6431
7115
  nativeEventSeq: journalSeq
6432
7116
  });
7117
+ if (activity.kind !== 'stream') renderContextUsage();
7118
+ }
7119
+
7120
+ function storeContextUsage(event, record, session, sequence) {
7121
+ if (!record) return false;
7122
+ const usage = Modules.ContextUsage.fromEvent(event, {
7123
+ ...record, providerRevision: Number(record.providerRevision || 0), eventSeq: sequence
7124
+ });
7125
+ if (!usage || !Modules.ContextUsage.isNewer(usage, record.contextUsage)) return false;
7126
+ record.contextUsage = usage;
7127
+ record.nativeEventSeq = Math.max(Number(record.nativeEventSeq || 0), Number(sequence || 0));
7128
+ if (session?.codexThreadId === usage.threadId
7129
+ && Modules.ContextUsage.isNewer(usage, session.contextUsage)) session.contextUsage = usage;
7130
+ return true;
6433
7131
  }
6434
7132
 
6435
7133
  function bindActiveTurn(detail = {}, journalSeq = 0) {
@@ -6438,16 +7136,24 @@
6438
7136
  }
6439
7137
  const threadId = String(detail.threadId || '');
6440
7138
  const turnId = String(detail.turnId || '');
6441
- currentRunView.activeTurn = { threadId, turnId };
7139
+ currentRunView.activeTurn = currentRunView.operation === 'compact' ? null : { threadId, turnId };
6442
7140
  const record = findRunRecord(currentRunView.recordId, currentRunView.sessionId);
6443
7141
  if (record) {
6444
7142
  record.codexThreadId = threadId; record.codexTurnId = turnId;
7143
+ if (record.operation === 'compact') {
7144
+ const session = findSessionById(currentRunView.sessionId);
7145
+ if (session?.contextUsage?.threadId === threadId
7146
+ && Date.parse(session.contextUsage.observedAt) < Date.parse(record.startedAt)) {
7147
+ session.contextUsage = null;
7148
+ }
7149
+ }
6445
7150
  record.nativeEventSeq = Math.max(Number(record.nativeEventSeq || 0), Number(journalSeq || 0));
6446
7151
  }
6447
7152
  if (threadId) {
6448
7153
  updateSessionById(currentRunView.sessionId, { codexThreadId: threadId });
6449
7154
  }
6450
7155
  renderPendingInputs();
7156
+ renderContextUsage();
6451
7157
  saveState().catch(() => {});
6452
7158
  }
6453
7159
 
@@ -6468,7 +7174,7 @@
6468
7174
  function appendRunEvent(input = {}) {
6469
7175
  const { title, status = 'info', detail, timestamp } = input;
6470
7176
  const safeTitle = sanitizeAssistantVisibleText(title) || 'Event';
6471
- if (!currentRunView?.events) {
7177
+ if (!currentRunView) {
6472
7178
  appendPlainLog(safeTitle);
6473
7179
  return;
6474
7180
  }
@@ -6512,21 +7218,20 @@
6512
7218
  })).filter(item => item.path)
6513
7219
  : undefined
6514
7220
  };
7221
+ // Keep the activity timestamp current without retaining a run reference
7222
+ // across session normalization, which replaces run and event objects.
7223
+ const nowMs = Date.now();
7224
+ if (nowMs - lastRunActivityBumpMs > 60000
7225
+ && findRunRecord(currentRunView.recordId, currentRunView.sessionId)) {
7226
+ lastRunActivityBumpMs = nowMs;
7227
+ state = updateActiveSession(state, {});
7228
+ }
6515
7229
  const record = findRunRecord(currentRunView.recordId, currentRunView.sessionId);
6516
7230
  let renderedEvent = event;
6517
7231
  if (record) {
6518
7232
  if (Number.isFinite(Number(input.nativeEventSeq))) {
6519
7233
  record.nativeEventSeq = Math.max(Number(record.nativeEventSeq || 0), Number(input.nativeEventSeq || 0));
6520
7234
  }
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
7235
  renderedEvent = event.kind === 'stream'
6531
7236
  ? upsertRunStreamRecordEvent(record, event)
6532
7237
  : event;
@@ -6922,7 +7627,7 @@
6922
7627
  }
6923
7628
  await Modules.ScopedPersistenceCoordinator.applyReviewTransition({
6924
7629
  getState: () => state, runId, transition: WritebackSettlement.applySettlementTransition(run, settlement),
6925
- persist: flushQueuedSaveState, assertCurrent: options.assertCurrent
7630
+ persist: flushQueuedSaveState, assertCurrent: options.assertCurrent, SessionState: Modules.SessionState
6926
7631
  });
6927
7632
  }
6928
7633
 
@@ -6939,6 +7644,7 @@
6939
7644
  if (!run) {
6940
7645
  return 0;
6941
7646
  }
7647
+ run = Modules.UndoOperations.withAvailableUndo(run);
6942
7648
  const trackedEditorUndoCount = hasTrackedEditorUndo(run) ? 1 : 0;
6943
7649
  if (trackedEditorUndoCount) {
6944
7650
  return trackedEditorUndoCount + Modules.UndoOperations.getFileUndoOperations(run).length;
@@ -6990,16 +7696,20 @@
6990
7696
  assertCurrent();
6991
7697
  }
6992
7698
 
6993
- async function settleWritebackRecovery(applied, project, verification, target) {
7699
+ async function settleWritebackRecovery(applied, project, verification, target, requireReviewing) {
6994
7700
  const record = findRunRecord(target.recordId, target.sessionId);
6995
7701
  if (!record) return;
7702
+ // retryRequireReviewing is in-memory only; a renormalized run copy loses it.
7703
+ const retryReviewing = typeof requireReviewing === 'boolean' ? requireReviewing
7704
+ : typeof record.retryRequireReviewing === 'boolean' ? record.retryRequireReviewing
7705
+ : record.executionSnapshot?.requireReviewing === true;
6996
7706
  const owner = recoveryOwner(target);
6997
7707
  const skippedPaths = new Set((applied.skipped || []).map(entry => entry.operation?.path));
6998
7708
  const creates = (record.retryWriteback?.operations || []).filter(op => skippedPaths.has(op.path));
6999
7709
  // Untouched edits (never opened / rejected) and deletes whose server check failed are safe to replay.
7000
7710
  const edits = (applied.skipped || []).filter(({ operation: op, result }) => result?.failure?.changedDocument === false && ((op?.type === 'edit'
7001
7711
  && (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 }),
7712
+ const base = { ...(record.retryWriteback || { ...owner, id: crypto.randomUUID() }), requireReviewing: retryReviewing,
7003
7713
  baseFiles: project?.files || [], requestId: applied.receiptUnconfirmed ? applied.requestId || '' : '' };
7004
7714
  // An oversized edit pre-image must not cost the create retries (the intent has a 300 KiB ceiling).
7005
7715
  const safeRetry = [[...creates, ...edits], creates].filter(ops => ops.length).map(ops => Modules.SessionState
@@ -7033,31 +7743,46 @@
7033
7743
  const originalStatus = record.status;
7034
7744
  const retryAutoRecompile = typeof record.executionSnapshot?.autoRecompile === 'boolean'
7035
7745
  ? record.executionSnapshot.autoRecompile : undefined;
7746
+ const generation = spaRouteGeneration;
7747
+ const isOwnerScope = () => generation === spaRouteGeneration
7748
+ && owner.projectId === getCurrentProjectId() && owner.accountScopeId === cachedAccountScopeId;
7036
7749
  // State is renormalized during the awaits below (probes, session sync), which
7037
7750
  // replaces run objects; always write through the current copy.
7038
- const live = () => findRunRecord(runId, session.id) || record;
7751
+ const live = () => (isOwnerScope() && findRunRecord(runId, session.id)) || record;
7039
7752
  let finished = false;
7753
+ let retryOutcome = null;
7040
7754
  runCancellationRequested = false;
7041
7755
  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,
7756
+ const requestId = record.nativeRequestId || `writeback:${owner.sessionId}:${runId}`;
7757
+ const ownerToken = crypto.randomUUID();
7758
+ const retryView = { recordId: runId, sessionId: session.id, runProjectId: owner.projectId,
7047
7759
  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]') };
7760
+ startedAt: Date.now(), writebackRetry: true };
7761
+ currentRunView = retryView;
7051
7762
  setRunning(true);
7052
7763
  const assertOwner = () => {
7053
- if (runCancellationRequested || currentRunView?.recordId !== runId) {
7764
+ if (runCancellationRequested || currentRunView !== retryView) {
7054
7765
  throw Object.assign(new Error('Save checking was cancelled.'), { code: 'codex_cancelled' });
7055
7766
  }
7056
- if (owner.projectId !== getCurrentProjectId() || owner.accountScopeId !== cachedAccountScopeId) {
7767
+ if (!isOwnerScope()) {
7057
7768
  throw Object.assign(new Error('The project or account changed.'), { code: 'aborted_project_changed' });
7058
7769
  }
7059
7770
  };
7060
7771
  try {
7772
+ const bound = await activeTurnControl.bindBrowserRun({ requestId, projectKey: owner.projectId,
7773
+ clientRunId: runId, sessionId: session.id, ownerToken });
7774
+ assertOwner();
7775
+ if (!bound) throw new Error(tx('Could not start sync. Reload the page and try again.', '无法开始同步,请刷新页面后重试。'));
7776
+ // Publish running only after another page can observe this browser owner.
7777
+ live().status = 'running';
7778
+ live().retryingWriteback = true;
7779
+ renderRunHistory({ preserveScroll: true });
7780
+ const root = panel.querySelector(`[data-run-id="${cssEscape(runId)}"]`);
7781
+ Object.assign(retryView, { root, events: root?.querySelector('[data-run-events]'),
7782
+ report: root?.querySelector('[data-run-report]'), status: root?.querySelector('[data-run-status]'),
7783
+ runProcess: root?.querySelector('[data-run-process]'), processLabel: root?.querySelector('[data-run-process-summary]') });
7784
+ const pendingMirror = getPendingMirrorRefresh?.();
7785
+ if (pendingMirror) await awaitRunStep(pendingMirror.catch(() => {}));
7061
7786
  assertOwner();
7062
7787
  if (intent?.requestId) {
7063
7788
  const receipt = await callPageBridge('getWritebackReceipt', { requestId: intent.requestId, runProjectId: owner.projectId });
@@ -7067,9 +7792,12 @@
7067
7792
  // Edits replay against the run's original base files, so the stale guard still refuses changed documents.
7068
7793
  const result = await applySyncChangesToOverleaf(intent.operations.map(op => op.type === 'edit'
7069
7794
  ? { 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,
7795
+ // The submitted snapshot owns the write mode; older intents may carry a lost value.
7796
+ { id: owner.projectId, files: intent.baseFiles }, { mode: 'auto', requireReviewing: typeof record.executionSnapshot?.requireReviewing === 'boolean'
7797
+ ? record.executionSnapshot.requireReviewing : intent.requireReviewing,
7071
7798
  autoRecompile: retryAutoRecompile,
7072
- retryCreates: true, assistantMessage: tx('Retried the remaining file transfers for this task.', '已重试本轮未完成的文件同步。') });
7799
+ retryCreates: true, assistantMessageKey: 'retry_sync', assistantMessage: tx('Retried the remaining file transfers for this task.', '已重试本轮未完成的文件同步。') });
7800
+ retryOutcome = result;
7073
7801
  assertOwner();
7074
7802
  if (result.settlement?.facts) live().settlement = WritebackSettlement.mergeRetrySettlement(live().settlement, result.settlement.facts); // Keep files outside the retry.
7075
7803
  await finishRunView(result.hasSkippedOperations ? tx('Sync incomplete', '同步未完成') : tx('Synced', '已同步'),
@@ -7090,15 +7818,36 @@
7090
7818
  }
7091
7819
  await flushQueuedSaveState();
7092
7820
  } catch (error) {
7821
+ const outcome = error.writebackOutcome || retryOutcome;
7822
+ const current = findRunRecord(runId, session.id);
7823
+ if (outcome && (runCancellationRequested || isRunCancellationError(error))
7824
+ && currentRunView === retryView && isOwnerScope()
7825
+ && current && !current.forkSnapshot && current.runProjectId === owner.projectId) {
7826
+ // Stop cannot erase writes returned by Retry sync, including a saved
7827
+ // reply that arrived after cancellation. Keep earlier files' facts too.
7828
+ if (outcome.settlement?.facts) current.settlement = WritebackSettlement.mergeRetrySettlement(
7829
+ current.settlement, outcome.settlement.facts);
7830
+ appendRunCancelledReport(outcome, false);
7831
+ await finishRunView(tx('Cancelled', '已取消'), 'cancelled');
7832
+ finished = true;
7833
+ }
7093
7834
  showPluginToast(error.message || String(error));
7094
7835
  } finally {
7095
7836
  // Only finishRunView settles a new terminal status; every other exit
7096
7837
  // returns the card to the status it had before the retry.
7838
+ if (currentRunView !== retryView) {
7839
+ activeTurnControl.release(requestId, ownerToken);
7840
+ return;
7841
+ }
7097
7842
  for (const copy of new Set([record, live()])) {
7098
7843
  delete copy.retryingWriteback;
7099
7844
  if (!finished && copy.status === 'running') copy.status = originalStatus;
7100
7845
  }
7101
- if (currentRunView?.recordId === runId) currentRunView = null;
7846
+ saveStateSoon();
7847
+ await flushQueuedSaveState().catch(() => {});
7848
+ activeTurnControl.release(requestId, ownerToken);
7849
+ if (currentRunView !== retryView) return;
7850
+ currentRunView = null;
7102
7851
  runCancellationRequested = false;
7103
7852
  runCancellationController = null;
7104
7853
  setRunning(false);
@@ -7206,11 +7955,18 @@
7206
7955
  return;
7207
7956
  }
7208
7957
 
7209
- const combinedAppliedOperations = [
7958
+ let combinedAppliedOperations = [
7210
7959
  ...(Array.isArray(record.appliedOperations) ? record.appliedOperations : []),
7211
7960
  ...appliedOperations
7212
7961
  ];
7962
+ const checkpoint = buildUndoCheckpoint(project, combinedAppliedOperations);
7963
+ combinedAppliedOperations = checkpoint.appliedOperations;
7213
7964
  record.appliedOperations = combinedAppliedOperations;
7965
+ if (checkpoint.undoUnavailable.length) {
7966
+ appendRunEvent({ title: tr('failureReason_snapshot_undo_unavailable_user'), status: 'warning',
7967
+ detail: checkpoint.undoUnavailable.map(item => ({ [tr('detailFile')]: item.path,
7968
+ [tr('detailReason')]: item.code, [tr('detailNext')]: tr('failureReason_snapshot_undo_unavailable_next') })) });
7969
+ }
7214
7970
  const createdCheckpoint = buildCreatedFileUndoCheckpoint(project, combinedAppliedOperations, record);
7215
7971
 
7216
7972
  if ((currentRunView?.executionSnapshot?.requireReviewing ?? state.requireReviewing) === true) {
@@ -7273,7 +8029,6 @@
7273
8029
  return;
7274
8030
  }
7275
8031
 
7276
- const checkpoint = buildUndoCheckpoint(project, combinedAppliedOperations);
7277
8032
  const createdPaths = new Set(createdCheckpoint.undoOperations.map(operation => operation.path));
7278
8033
  checkpoint.undoOperations = [
7279
8034
  ...checkpoint.undoOperations.filter(operation => !createdPaths.has(operation.path)),
@@ -7283,10 +8038,7 @@
7283
8038
  ...checkpoint.undoBaseFiles.filter(file => !createdPaths.has(file.path)),
7284
8039
  ...createdCheckpoint.undoBaseFiles
7285
8040
  ];
7286
- if (!checkpoint.undoOperations.length) {
7287
- return;
7288
- }
7289
-
8041
+ if (!checkpoint.undoOperations.length && !checkpoint.undoUnavailable.length) return;
7290
8042
  record.undoOperations = checkpoint.undoOperations;
7291
8043
  record.undoBaseFiles = checkpoint.undoBaseFiles;
7292
8044
  record.undoTrackedChanges = [];
@@ -7299,6 +8051,7 @@
7299
8051
  record.undoStatus = '';
7300
8052
  record.partialWriteback = skippedEntries.length > 0;
7301
8053
  refreshRunCardControls(record.id);
8054
+ if (!checkpoint.undoOperations.length) return;
7302
8055
  appendRunEvent({
7303
8056
  title: tr('undoCheckpointPlain', { count: record.undoOperations.length }),
7304
8057
  status: 'completed',
@@ -7361,6 +8114,7 @@
7361
8114
  guidanceId: sanitizeAssistantVisibleText(event.guidanceId)
7362
8115
  };
7363
8116
  if (event.failure) normalized.failure = sanitizeAssistantVisibleValue(event.failure);
8117
+ if (event.detailStructured) normalized.detailStructured = sanitizeAssistantVisibleValue(event.detailStructured);
7364
8118
  record.events = [...(record.events || []), normalized].slice(-MAX_RUN_EVENTS);
7365
8119
  saveStateSoon();
7366
8120
 
@@ -7810,8 +8564,14 @@
7810
8564
  return;
7811
8565
  }
7812
8566
 
8567
+ const groupKey = String(options.groupKey || '');
8568
+ const grouped = groupKey && Array.from(region.children).find(item => item.dataset.groupKey === groupKey);
8569
+ if (grouped) {
8570
+ updateToastGroup(grouped);
8571
+ return;
8572
+ }
7813
8573
  const last = region.lastElementChild;
7814
- if (last?.classList?.contains('codex-toast') && last.dataset.baseText === value) {
8574
+ if (!groupKey && !last?.dataset.groupKey && last?.classList?.contains('codex-toast') && last.dataset.baseText === value) {
7815
8575
  const repeatCount = Number(last.dataset.repeatCount || '1') + 1;
7816
8576
  last.dataset.repeatCount = String(repeatCount);
7817
8577
  const textNode = last.querySelector('[data-toast-text]');
@@ -7829,15 +8589,34 @@
7829
8589
 
7830
8590
  const body = document.createElement('div');
7831
8591
  body.className = 'codex-toast-body';
7832
- body.dataset.toastText = 'true';
7833
- body.textContent = value;
8592
+ if (options.title) {
8593
+ const title = document.createElement('div');
8594
+ title.className = 'codex-toast-title';
8595
+ title.textContent = options.title;
8596
+ body.append(title);
8597
+ }
8598
+ const textNode = document.createElement('div');
8599
+ textNode.dataset.toastText = 'true';
8600
+ textNode.textContent = value;
8601
+ body.append(textNode);
8602
+ if (groupKey) {
8603
+ item.dataset.groupKey = groupKey;
8604
+ const details = document.createElement('details');
8605
+ details.className = 'codex-toast-group';
8606
+ const summary = document.createElement('summary');
8607
+ const list = document.createElement('ul');
8608
+ list.dataset.toastGroupItems = 'true';
8609
+ details.append(summary, list);
8610
+ body.append(details);
8611
+ }
7834
8612
  item.append(body);
8613
+ if (groupKey) updateToastGroup(item);
7835
8614
 
7836
8615
  const close = document.createElement('button');
7837
8616
  close.type = 'button';
7838
8617
  close.className = 'codex-toast-close';
7839
8618
  close.setAttribute('aria-label', tr('dismissNotification'));
7840
- close.textContent = '×';
8619
+ close.innerHTML = Modules.PanelIcons.svg('close', 'codex-close-glyph');
7841
8620
  close.addEventListener('click', () => item.remove());
7842
8621
  item.append(close);
7843
8622
 
@@ -7851,6 +8630,20 @@
7851
8630
  item.remove();
7852
8631
  }, 5200);
7853
8632
  }
8633
+
8634
+ function updateToastGroup(toast) {
8635
+ const list = toast.querySelector('[data-toast-group-items]');
8636
+ const value = String(options.groupItem || '').trim();
8637
+ if (value && !Array.from(list.children).some(item => item.textContent === value)) {
8638
+ const item = document.createElement('li');
8639
+ const code = document.createElement('code');
8640
+ code.textContent = value;
8641
+ item.append(code);
8642
+ list.append(item);
8643
+ }
8644
+ toast.querySelector('.codex-toast-group summary').textContent =
8645
+ options.groupSummary?.(list.children.length) || String(list.children.length);
8646
+ }
7854
8647
  }
7855
8648
 
7856
8649
  function updateProbeNotice(text) {