codex-overleaf-link 2.5.0 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (189) hide show
  1. package/README.md +155 -392
  2. package/README.zh-CN.md +154 -393
  3. package/extension/bootstrap/background.js +52 -9
  4. package/extension/bootstrap/manifest.template.json +1 -1
  5. package/extension/bootstrap/popup.html +1 -0
  6. package/extension/bootstrap/popupFallback.js +55 -0
  7. package/extension/bootstrap/updateStatus.js +1 -0
  8. package/extension/src/background.js +283 -55
  9. package/extension/src/backgroundBootstrapMigration.js +181 -0
  10. package/extension/src/backgroundSharedSessions.js +129 -16
  11. package/extension/src/backgroundUpdateCoordinator.js +242 -29
  12. package/extension/src/backgroundUpdateSafety.js +173 -0
  13. package/extension/src/content/activeTurnControl.js +171 -8
  14. package/extension/src/content/applyResultFormatters.js +46 -73
  15. package/extension/src/content/assetTransferBroker.js +72 -14
  16. package/extension/src/content/composerAttachments.js +104 -47
  17. package/extension/src/content/composerPanel.js +110 -12
  18. package/extension/src/content/contentRuntime.js +1555 -926
  19. package/extension/src/content/contextTray.js +8 -2
  20. package/extension/src/content/contextUsageView.js +267 -0
  21. package/extension/src/content/diagnosticsController.js +122 -96
  22. package/extension/src/content/diagnosticsExport.js +19 -5
  23. package/extension/src/content/diagnosticsPanel.js +48 -0
  24. package/extension/src/content/directoryCleanupRecovery.js +54 -0
  25. package/extension/src/content/generated/content.bundle.js +11077 -3412
  26. package/extension/src/content/generated/content.bundle.meta.json +14 -3
  27. package/extension/src/content/globalPreferencesController.js +1 -1
  28. package/extension/src/content/legacyGlobalRegistry.js +10 -0
  29. package/extension/src/content/localSkillsPanel.js +104 -14
  30. package/extension/src/content/markdownDomRenderer.js +5 -3
  31. package/extension/src/content/markdownText.js +47 -11
  32. package/extension/src/content/modelPicker.js +8 -4
  33. package/extension/src/content/nativeCompatibilityController.js +74 -30
  34. package/extension/src/content/otWarmMirror.js +5 -6
  35. package/extension/src/content/otWarmMirrorController.js +17 -20
  36. package/extension/src/content/pageBridgeClient.js +17 -4
  37. package/extension/src/content/panelMaintenance.js +46 -14
  38. package/extension/src/content/panelRenderer.js +57 -27
  39. package/extension/src/content/pendingInputView.js +1 -1
  40. package/extension/src/content/projectProviderSelection.js +13 -4
  41. package/extension/src/content/projectSessionCleanup.js +109 -62
  42. package/extension/src/content/projectSettingsCoordinator.js +67 -6
  43. package/extension/src/content/providerSettingsCoordinator.js +46 -8
  44. package/extension/src/content/providerSettingsDialog.js +73 -14
  45. package/extension/src/content/recentProjects.js +15 -8
  46. package/extension/src/content/runActivitySummary.js +160 -18
  47. package/extension/src/content/runCompletionMeta.js +532 -0
  48. package/extension/src/content/runController.js +39 -15
  49. package/extension/src/content/runFailureNotice.js +29 -13
  50. package/extension/src/content/runGuidanceController.js +72 -27
  51. package/extension/src/content/runGuidanceView.js +59 -10
  52. package/extension/src/content/runPresence.js +18 -8
  53. package/extension/src/content/runQueueScheduler.js +157 -47
  54. package/extension/src/content/runRecoveryActions.js +147 -0
  55. package/extension/src/content/runResultActions.js +28 -143
  56. package/extension/src/content/runReviewController.js +633 -0
  57. package/extension/src/content/runScrollLayout.js +21 -2
  58. package/extension/src/content/runTimelineView.js +367 -175
  59. package/extension/src/content/scopedPersistencePanelState.js +134 -20
  60. package/extension/src/content/selectionContextView.js +1 -1
  61. package/extension/src/content/sensitivePreflight.js +153 -0
  62. package/extension/src/content/sessionForkController.js +80 -17
  63. package/extension/src/content/sessionManager.js +50 -4
  64. package/extension/src/content/sessionMenuView.js +54 -18
  65. package/extension/src/content/sessionPersistence.js +25 -5
  66. package/extension/src/content/settingsPanel.js +22 -5
  67. package/extension/src/content/settingsWorkbench.js +132 -89
  68. package/extension/src/content/sharedSessionViewSync.js +49 -11
  69. package/extension/src/content/subagentActivityView.js +23 -7
  70. package/extension/src/content/updateIdle.js +4 -1
  71. package/extension/src/content/updateNotice.js +118 -12
  72. package/extension/src/content/updateSafetyReceiver.js +58 -0
  73. package/extension/src/content/writebackController.js +53 -4
  74. package/extension/src/content/writebackOrchestrator.js +116 -94
  75. package/extension/src/content/writingStyleSettings.js +35 -11
  76. package/extension/src/page/binaryAssetUploader.js +136 -14
  77. package/extension/src/page/compileBridge.js +193 -180
  78. package/extension/src/page/fileMover.js +256 -0
  79. package/extension/src/page/overleafCapabilities.js +4 -1
  80. package/extension/src/page/ownedDirectoryCleanup.js +94 -0
  81. package/extension/src/page/referenceProjects.js +1 -0
  82. package/extension/src/page/reviewContext.js +113 -0
  83. package/extension/src/page/saveState.js +6 -4
  84. package/extension/src/page/snapshotRouter.js +84 -39
  85. package/extension/src/page/textCoordinates.js +17 -1
  86. package/extension/src/page/textFileCreator.js +114 -26
  87. package/extension/src/page/trackedChangeCapture.js +12 -2
  88. package/extension/src/page/trackedChangeOwnership.js +19 -1
  89. package/extension/src/page/trackedChangeReplay.js +41 -21
  90. package/extension/src/page/trackedChangesLifecycle.js +217 -199
  91. package/extension/src/page/treeOperations.js +7 -10
  92. package/extension/src/page/updateSafetyProbe.js +41 -0
  93. package/extension/src/page/writebackReceiptJournal.js +2 -0
  94. package/extension/src/page/writebackRouter.js +103 -155
  95. package/extension/src/pageBridge.js +126 -69
  96. package/extension/src/popup.js +62 -10
  97. package/extension/src/shared/agentTranscript.js +373 -17
  98. package/extension/src/shared/auditRecords.js +4 -0
  99. package/extension/src/shared/compatibility.js +22 -6
  100. package/extension/src/shared/compileAdapter.js +129 -66
  101. package/extension/src/shared/composerAttachmentDraft.js +156 -0
  102. package/extension/src/shared/contextUsage.js +125 -0
  103. package/extension/src/shared/directoryCleanupProof.js +43 -0
  104. package/extension/src/shared/failureReasons.js +349 -4
  105. package/extension/src/shared/globalPreferences.js +30 -13
  106. package/extension/src/shared/governanceRules.js +4 -37
  107. package/extension/src/shared/i18n.js +590 -24
  108. package/extension/src/shared/lineReferences.js +38 -31
  109. package/extension/src/shared/managedUpdateProjection.js +2 -2
  110. package/extension/src/shared/nativeRequestIdentity.js +1 -0
  111. package/extension/src/shared/pageRpcContract.js +5 -4
  112. package/extension/src/shared/panelIcons.js +39 -0
  113. package/extension/src/shared/projectFiles.js +21 -44
  114. package/extension/src/shared/providerProfiles.js +5 -3
  115. package/extension/src/shared/reviewing.js +130 -1
  116. package/extension/src/shared/runActivityModel.js +70 -4
  117. package/extension/src/shared/runExecutionSnapshot.js +13 -0
  118. package/extension/src/shared/runInputQueue.js +48 -5
  119. package/extension/src/shared/selectionContext.js +12 -3
  120. package/extension/src/shared/sessionState.js +132 -28
  121. package/extension/src/shared/sessionTitle.js +14 -1
  122. package/extension/src/shared/settlementFacts.js +10 -2
  123. package/extension/src/shared/sharedSessionBridge.js +118 -27
  124. package/extension/src/shared/sharedSessionTransfer.js +140 -0
  125. package/extension/src/shared/staleGuard.js +166 -52
  126. package/extension/src/shared/storageDb.js +90 -26
  127. package/extension/src/shared/storageMigration.js +78 -112
  128. package/extension/src/shared/storageRunActions.js +267 -30
  129. package/extension/src/shared/subagentActivityModel.js +1 -1
  130. package/extension/src/shared/undoOperations.js +253 -32
  131. package/extension/src/shared/updateConsent.js +6 -4
  132. package/extension/src/shared/updateRuntimeIdentity.js +17 -3
  133. package/extension/src/shared/writebackIntent.js +16 -0
  134. package/extension/src/shared/writebackOperationEvidence.js +4 -15
  135. package/extension/src/shared/writebackSettlement.js +58 -40
  136. package/extension/styles/panel.css +1123 -493
  137. package/native-host/src/anthropicBridgeRequest.js +4 -2
  138. package/native-host/src/anthropicBridgeResponse.js +6 -2
  139. package/native-host/src/anthropicMessagesBridge.js +24 -16
  140. package/native-host/src/bootstrapMigration.js +296 -0
  141. package/native-host/src/bootstrapMigrationContract.js +101 -0
  142. package/native-host/src/bootstrapUpdate.js +269 -0
  143. package/native-host/src/bootstrapUpdateWatchdog.js +44 -0
  144. package/native-host/src/chatBridgeRequest.js +60 -6
  145. package/native-host/src/chatBridgeResponse.js +20 -1
  146. package/native-host/src/chatCompletionsBridge.js +48 -15
  147. package/native-host/src/codexConfigPaths.js +106 -0
  148. package/native-host/src/codexHome.js +66 -73
  149. package/native-host/src/codexHomeScope.js +124 -0
  150. package/native-host/src/codexInheritedAuth.js +90 -0
  151. package/native-host/src/codexLocalSkills.js +301 -0
  152. package/native-host/src/codexPromptAssembly.js +114 -127
  153. package/native-host/src/codexProviderTest.js +24 -26
  154. package/native-host/src/codexSessionRunner.js +234 -198
  155. package/native-host/src/codexSessionTiming.js +18 -8
  156. package/native-host/src/codexSkillIsolation.js +170 -0
  157. package/native-host/src/codexThreadFork.js +19 -3
  158. package/native-host/src/index.js +19 -7
  159. package/native-host/src/managedInstall.js +110 -35
  160. package/native-host/src/managedInstallRecovery.js +140 -0
  161. package/native-host/src/managedRuntimeEvidence.js +57 -0
  162. package/native-host/src/mirrorWorkspace.js +92 -22
  163. package/native-host/src/nativeDoctor.js +15 -0
  164. package/native-host/src/nativeHelperLifecycle.js +21 -0
  165. package/native-host/src/nativeHostRegistration.js +33 -0
  166. package/native-host/src/nativeRunLifecycle.js +71 -0
  167. package/native-host/src/processStopBarrier.js +42 -7
  168. package/native-host/src/projectPathPlatform.js +30 -0
  169. package/native-host/src/providerContextError.js +20 -0
  170. package/native-host/src/providerHttp.js +80 -0
  171. package/native-host/src/providerProfile.js +4 -2
  172. package/native-host/src/providerReasoning.js +2 -7
  173. package/native-host/src/providerRuntime.js +1 -0
  174. package/native-host/src/providerStore.js +72 -14
  175. package/native-host/src/responsesPassthroughBridge.js +266 -52
  176. package/native-host/src/runtimeInstaller.js +21 -1
  177. package/native-host/src/selectionScope.js +36 -8
  178. package/native-host/src/subagentAdmission.js +26 -0
  179. package/native-host/src/subagentBroker.js +79 -66
  180. package/native-host/src/subagentTelemetry.js +19 -10
  181. package/native-host/src/subagentWorkspacePath.js +5 -2
  182. package/native-host/src/taskRunnerRuntime.js +92 -57
  183. package/native-host/src/textPatch.js +473 -377
  184. package/native-host/src/tomlStatements.js +140 -0
  185. package/native-host/src/updateManager.js +409 -46
  186. package/native-host/src/writingStyleRuntime.js +57 -29
  187. package/native-host/src/writingStyleStore.js +73 -0
  188. package/package.json +5 -8
  189. package/scripts/install-managed.mjs +1 -0
@@ -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;
@@ -56,7 +57,12 @@
56
57
  'codex.history.clearPlugin',
57
58
  'skills.list',
58
59
  'skills.install',
59
- 'skills.remove'
60
+ 'skills.remove',
61
+ 'writing-style.get',
62
+ 'writing-style.set',
63
+ 'codex.thread.fork',
64
+ 'asset.readChunk',
65
+ 'asset.release'
60
66
  ]);
61
67
  const PageRpcContract = Modules.PageRpcContract;
62
68
  const PANEL_DEFAULT_WIDTH = 380;
@@ -64,7 +70,9 @@
64
70
  const PANEL_MAX_WIDTH = 760;
65
71
  const PAGE_MIN_WIDTH = 520;
66
72
  const CodexOverleafCompatibility = Modules.Compatibility;
67
- const INSTALL_COMMAND = CodexOverleafCompatibility?.buildInstallCommand?.(
73
+ const MANAGED_INSTALLATION = chrome.runtime.getManifest?.()?.background?.service_worker === 'bootstrap/background.js'
74
+ || root.__CODEX_OVERLEAF_RUNTIME_PREFIX__ === 'runtime/';
75
+ const INSTALL_COMMAND = MANAGED_INSTALLATION ? '' : CodexOverleafCompatibility?.buildInstallCommand?.(
68
76
  undefined,
69
77
  undefined,
70
78
  getCurrentExtensionId()
@@ -91,7 +99,8 @@
91
99
  recordSessionResult,
92
100
  selectVisibleSessionsForList,
93
101
  setActiveSession,
94
- updateActiveSession
102
+ updateActiveSession,
103
+ updateActiveSessionDraft
95
104
  } = Modules.SessionState;
96
105
  const LineReferences = Modules.LineReferences;
97
106
  const { getProjectStorageKey } = Modules.StorageKeys;
@@ -207,6 +216,8 @@
207
216
  closeSlashMenu: () => closeSlashMenu(),
208
217
  getCurrentProjectId: () => getCurrentProjectId(),
209
218
  getLocalSkillsPanel: () => localSkillsPanel,
219
+ getCodexLocalSkills: () => getCodexLocalSkillsCatalog(),
220
+ onSkillsChange: () => updateSlashMenuForTaskInput(),
210
221
  getLocale: () => getLocale(),
211
222
  getPanel: () => panel,
212
223
  getPanelRendererInstance: () => panelRendererInstance,
@@ -229,6 +240,9 @@
229
240
  closeCustomInstructionsSettings,
230
241
  closeSkillsView,
231
242
  getCodexOverleafSkillEnabled,
243
+ getCodexLocalSkillEnabled,
244
+ isCodexLocalSkillEnabled,
245
+ setCodexLocalSkillEnabled,
232
246
  getCustomInstructionsForCurrentProject,
233
247
  getGovernanceRulesForCurrentProject,
234
248
  getSkillLoadingSettings,
@@ -335,6 +349,7 @@
335
349
  otWarmMirrorController,
336
350
  getActiveFocusFiles,
337
351
  getCurrentProjectId,
352
+ getScope: () => ({ accountScopeId: cachedAccountScopeId, projectId: getCurrentProjectId(), generation: spaRouteGeneration }),
338
353
  isExperimentalOtEnabled,
339
354
  readOtBridgeStatus,
340
355
  formatOtStatusLabel,
@@ -383,9 +398,17 @@
383
398
  openCustomInstructionsSettings: () => openCustomInstructionsSettings(),
384
399
  onHistoryRowJump: record => jumpToHistoryRun(record),
385
400
  resetProjectNameCache: () => recentProjects.resetProjectNameCache(),
401
+ clearHistoryComposer: () => {
402
+ pendingRetryReplacement = null;
403
+ clearTaskComposer();
404
+ persistComposerAttachmentsToState();
405
+ },
386
406
  getPanel: () => panel,
387
407
  getState: () => state,
388
- setState: next => { state = next; },
408
+ setState: next => {
409
+ if (next.activeSessionId !== state?.activeSessionId) composerDraftEditedHere = false;
410
+ state = next;
411
+ },
389
412
  getCurrentRunView: () => currentRunView,
390
413
  getSettingsPanelInstance: () => settingsPanelInstance,
391
414
  prepareRetryReplacement: (run, task) => prepareRetryReplacement(run, task)
@@ -406,30 +429,45 @@
406
429
  const sessionForkController = Modules.SessionForkController.create({
407
430
  SessionState: Modules.SessionState,
408
431
  getState: () => state,
409
- setState: next => { state = next; },
410
- sendBackgroundNative,
411
- saveState,
432
+ setState: next => {
433
+ if (next.activeSessionId !== state?.activeSessionId) composerDraftEditedHere = false;
434
+ state = next;
435
+ },
436
+ getScope: () => ({ accountScopeId: cachedAccountScopeId, projectId: getCurrentProjectId(), generation: spaRouteGeneration }),
437
+ sendBackgroundNative, saveState,
438
+ getEnabledCodexLocalSkillIds: () => getEnabledCodexLocalSkillIds(),
439
+ captureLocalSkillIdsForFork: async preferences => {
440
+ if (preferences.loadCodexLocalSkills === false) return [];
441
+ const choices = { ...preferences.codexLocalSkillEnabled };
442
+ const skills = await primeCodexLocalSkillsCatalog({ required: true });
443
+ return skills.filter(skill => Object.hasOwn(choices, skill.id) ? choices[skill.id] !== false : skill.enabled !== false).map(skill => skill.id);
444
+ },
412
445
  applyStateToPanel,
413
446
  showPluginToast,
414
447
  tr
415
448
  });
416
449
  const runResultActions = Modules.RunResultActions.create({
417
450
  tr,
418
- getLocale,
419
451
  forkRunFromNode: sessionForkController.forkRunFromNode
420
452
  });
421
453
  const runTimelineView = Modules.RunTimelineView.create({
422
454
  RunActivitySummary: Modules.RunActivitySummary,
423
455
  RunPresence: Modules.RunPresence,
456
+ PanelIcons: Modules.PanelIcons,
424
457
  // recentProjects is composed below; the empty state only reads it after startup.
425
458
  getProjectName: () => { try { return recentProjects.readCurrentProjectNameFromDom(); } catch { return ''; } },
426
- getCurrentProjectId: () => getCurrentProjectId(),
427
459
  RunActivityModel: Modules.RunActivityModel,
428
460
  SubagentActivityView: Modules.SubagentActivityView,
429
461
  RunGuidanceView: Modules.RunGuidanceView,
462
+ getPendingGuidance: run => runGuidanceController.getPending(run),
430
463
  RunResultActions: runResultActions,
431
464
  RunScrollLayout: Modules.RunScrollLayout,
432
465
  RunFailureNotice: Modules.RunFailureNotice,
466
+ I18n: i18n,
467
+ FailureReasons,
468
+ projectContextLimitEvent: event => Modules.AgentTranscript.projectContextLimitEvent(event, getLocale()),
469
+ localizeVisibleReason: value => localizeVisibleReason(value),
470
+ localizeCompileReason: value => Modules.CompileAdapter.localizeCompileReason(value, getLocale()),
433
471
  tr,
434
472
  tx,
435
473
  getLocale,
@@ -447,7 +485,8 @@
447
485
  cssEscape,
448
486
  renderMarkdownInlineText,
449
487
  renderMarkdownBlockText,
450
- decorateCompletionReport: Modules.MarkdownDomRenderer.decorateCompletionReport,
488
+ decorateCompletionReport: (container, options) => Modules.MarkdownDomRenderer.decorateCompletionReport(container,
489
+ { ...options, formatOperationType }),
451
490
  sanitizeAssistantVisibleText,
452
491
  sanitizeAssistantVisibleValue,
453
492
  buildMarkdownInlineNodes,
@@ -457,6 +496,8 @@
457
496
  refillComposerForRetry,
458
497
  canEditRun: run => runController.isRetryReplacementEligible(run, WritebackSettlement.projectRunSettlement(run)),
459
498
  retryWriteback: retryRunWriteback,
499
+ getCurrentProjectId,
500
+ getAccountScopeId: () => cachedAccountScopeId,
460
501
  showNativeSetupGuidance: () => showNativeUpdateGuidanceModal({}),
461
502
  openProjectFileForFailure,
462
503
  openStorageSettings,
@@ -489,6 +530,7 @@
489
530
  SessionPanel: Modules.SessionPanel,
490
531
  SessionState: Modules.SessionState,
491
532
  SessionMenuView: Modules.SessionMenuView,
533
+ PanelIcons: Modules.PanelIcons,
492
534
  tr,
493
535
  showPluginConfirm,
494
536
  showPluginToast,
@@ -496,10 +538,29 @@
496
538
  saveState,
497
539
  saveStateSoon,
498
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,
499
557
  applyStateToPanel, syncSessionProvider: () => providerSettingsCoordinator.syncSessionProvider(),
500
558
  getPanel: () => panel,
501
559
  getState: () => state,
502
- setState: next => { state = next; },
560
+ setState: next => {
561
+ if (next.activeSessionId !== state?.activeSessionId) composerDraftEditedHere = false;
562
+ state = next;
563
+ },
503
564
  getSessionPanelInstance: () => sessionPanelInstance,
504
565
  formatSessionTime
505
566
  });
@@ -563,6 +624,7 @@
563
624
  selectionContext: value,
564
625
  ...(options.edit ? { mode: 'auto' } : {})
565
626
  });
627
+ syncProbeStatus();
566
628
  if (options.edit && panel) {
567
629
  const modeInput = panel.querySelector('[data-mode]');
568
630
  if (modeInput) modeInput.value = 'auto';
@@ -609,6 +671,14 @@
609
671
  locale: getLocale()
610
672
  };
611
673
  },
674
+ preflightProjectReferences: ({ project, assertCurrent }) => {
675
+ assertCurrent();
676
+ return Modules.SensitivePreflight.create({
677
+ SensitiveScan, normalizeGovernanceRules, isTextSnapshotFile, getCurrentProjectId,
678
+ appendRunEvent: () => {}, appendCompletionReport: () => {},
679
+ showPluginConfirm, tr, tx
680
+ }).run({ task: '', project, rules: getGovernanceRulesForCurrentProject(), assertCurrent });
681
+ },
612
682
  startBuild: async params => {
613
683
  const gate = await nativeCompatibilityController.ensureForMethod('codex.run');
614
684
  if (!gate.ok) throw new Error(gate.response?.error?.message || 'Native host update required.');
@@ -722,6 +792,7 @@
722
792
  || { providerId: state?.providerId || 'builtin', providerRevision: 0 },
723
793
  readSelectedSpeedInput,
724
794
  getRenderedModelEntries,
795
+ onSelectionRendered: renderContextUsage,
725
796
  persistPanelInputs,
726
797
  closeDiagnosticsMenu,
727
798
  closeCustomInstructionsSettings,
@@ -765,8 +836,10 @@
765
836
  window,
766
837
  getSettingsPanelInstance: () => settingsPanelInstance,
767
838
  getSelectedModel: () => state?.model || '', getSelectedProviderId: () => state?.providerId || 'builtin',
839
+ getSelectedProviderRevision: () => state?.providerRevision,
768
840
  setSelectedProviderId: projectProviderSelection.commit,
769
841
  confirmProviderSwitch: projectProviderSelection.confirmSwitch,
842
+ assertProviderSwitchAllowed: projectProviderSelection.assertSwitchAllowed,
770
843
  refreshModelOptions: loadModelOptions, persistInputs: persistPanelInputs
771
844
  });
772
845
  // v1.8.1 D3: the dashboard was a one-shot render — another tab finishing a
@@ -802,8 +875,11 @@
802
875
  document.addEventListener('visibilitychange', () => {
803
876
  if (document.visibilityState === 'visible') {
804
877
  refreshDashboardSoon();
878
+ } else {
879
+ flushComposerDraftOnLeave();
805
880
  }
806
881
  });
882
+ window.addEventListener('pagehide', flushComposerDraftOnLeave);
807
883
 
808
884
  const recentProjects = Modules.RecentProjects.create({
809
885
  ProjectSessionCleanup: Modules.ProjectSessionCleanup,
@@ -845,9 +921,11 @@
845
921
  const MAX_ATTACHMENT_PREVIEW_DATA_URL_CHARS = 768 * 1024;
846
922
  const composerAttachmentController = Modules.ComposerAttachments.createComposerAttachmentController({
847
923
  getPanel: () => panel,
924
+ icons: Modules.PanelIcons,
848
925
  tr,
849
926
  tx,
850
927
  appendPlainLog,
928
+ onChange: () => getComposerDraftPersistence().changed(),
851
929
  limits: {
852
930
  maxAttachmentBytes: MAX_COMPOSER_ATTACHMENT_BYTES,
853
931
  maxAttachmentTotalBytes: MAX_COMPOSER_ATTACHMENT_TOTAL_BYTES,
@@ -874,11 +952,14 @@
874
952
  });
875
953
  const contextTrayController = ContextTray.createContextTrayController({
876
954
  root: window,
955
+ icons: Modules.PanelIcons,
877
956
  document,
878
957
  getPanel: () => panel,
879
958
  getState: () => state,
880
959
  setState: nextState => {
881
960
  state = nextState;
961
+ // Paint focus changes before ContextTray waits for durable persistence.
962
+ syncProbeStatus();
882
963
  },
883
964
  saveState,
884
965
  saveStateSoon,
@@ -905,9 +986,15 @@
905
986
  saveState,
906
987
  sendBackgroundNative,
907
988
  getCurrentProjectId,
989
+ getScope: () => getCodexLocalSkillsCatalogScope(),
908
990
  getSkillLoadingSettings,
909
991
  isCodexOverleafSkillEnabled,
910
992
  setCodexOverleafSkillEnabled,
993
+ isCodexLocalSkillEnabled,
994
+ setCodexLocalSkillEnabled,
995
+ getCodexLocalSkills: () => getCodexLocalSkillsCatalog(),
996
+ getCodexLocalSkillWarnings: () => getCodexLocalSkillWarnings(),
997
+ refreshCodexLocalSkills: options => primeCodexLocalSkillsCatalog(options),
911
998
  setProjectSettingsStatus,
912
999
  tr,
913
1000
  tx,
@@ -936,15 +1023,22 @@
936
1023
  let settingsPanelInstance = null;
937
1024
  let diagnosticsPanelInstance = null;
938
1025
  let composerPanelInstance = null;
1026
+ let contextUsageView = null;
939
1027
  let state = null;
940
1028
  let storageKey = LEGACY_STORAGE_KEY;
941
1029
  let globalPreferencesController = null;
942
1030
  let currentRunView = null;
1031
+ let runSubmissionPending = false;
943
1032
  let pendingRetryReplacement = null;
944
1033
  let runQueueScheduler = null;
1034
+ let deferredSessionStart = null;
945
1035
  let runInputCoordinator = null;
946
1036
  const activeTurnControl = ActiveTurnControl.create({ chrome });
947
1037
  let saveStateTimer = null;
1038
+ const DRAFT_SAVE_DELAY_MS = 800;
1039
+ const DRAFT_SAVE_MAX_WAIT_MS = 4000;
1040
+ let draftSaveTimer = null;
1041
+ let pendingDraftSave = null;
948
1042
  // Two-phase saveState scheduling. `saveStateInFlight` flips to true while
949
1043
  // an async saveState() is actually writing; `saveStateRunAfterFlight` is
950
1044
  // set when a fresh saveStateSoon() fires during the in-flight phase. When
@@ -960,6 +1054,9 @@
960
1054
  let pendingStreamRenderEvents = new Map();
961
1055
  let storageNoticeKeys = new Set();
962
1056
  let composerSkillInvocation = null;
1057
+ let composerDraftRevision = 0;
1058
+ // Page-local provenance only; the draft itself stays shared per conversation.
1059
+ let composerDraftEditedHere = false;
963
1060
  let slashCodexOverleafSkills = [];
964
1061
  let slashCodexOverleafSkillsLoaded = false;
965
1062
  let slashCodexOverleafSkillsLoading = false;
@@ -982,6 +1079,7 @@
982
1079
  showPluginToast,
983
1080
  getPanel: () => panel,
984
1081
  ensurePanelOpen,
1082
+ getIconUrl: () => chrome.runtime.getURL('assets/icons/codex-overleaf-dialog-icon.png'),
985
1083
  getPanelRendererInstance: () => panelRendererInstance,
986
1084
  setBadge: (...args) => PanelRenderer.setBadge(...args),
987
1085
  setDiagnosticsHealth,
@@ -1000,7 +1098,7 @@
1000
1098
  getBusyState: () => ({
1001
1099
  run: Boolean(currentRunView),
1002
1100
  cancelling: runCancellationRequested,
1003
- storage: saveStateInFlight || Boolean(saveStateTimer),
1101
+ storage: saveStateInFlight || Boolean(saveStateTimer) || Boolean(pendingDraftSave),
1004
1102
  reviewAction: trackedChangeInFlight.size > 0,
1005
1103
  dialog: Boolean(pluginConfirmController?.isOpen())
1006
1104
  }),
@@ -1017,6 +1115,7 @@
1017
1115
  const diagnosticsExporter = Modules.DiagnosticsExport.create({
1018
1116
  root, window, document, Blob, URL, TextEncoder, AuditRecords, StorageDb: Modules.StorageDb,
1019
1117
  CodexOverleafCompatibility, getState: () => state, getCurrentRunView: () => currentRunView,
1118
+ getAccountScopeId: () => cachedAccountScopeId || '',
1020
1119
  getCurrentProjectId, findRunRecord, getMirrorFreshness, getExtensionCompatibilityMetadata,
1021
1120
  getModelDiscovery, getGovernanceRulesForCurrentProject, sendBackgroundNative,
1022
1121
  fallbackNativeCompatibility, isNativeCompatibilityCompatible, getNativeCompatibilityClassification,
@@ -1029,7 +1128,7 @@
1029
1128
  chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
1030
1129
  if (message?.type === 'codex-overleaf/runtime-health-probe') {
1031
1130
  sendResponse?.({
1032
- ok: true,
1131
+ ok: true, runOwnerProtocol: 2, runOwners: [activeTurnControl.getOwner()].filter(Boolean),
1033
1132
  version: CodexOverleafCompatibility?.BUILD_TARGET_VERSION || chrome.runtime.getManifest().version
1034
1133
  });
1035
1134
  return false;
@@ -1094,6 +1193,8 @@
1094
1193
  await refreshAccountScopeId();
1095
1194
  storageKey = getProjectStorageKey(LEGACY_STORAGE_KEY, window.location.href);
1096
1195
  state = normalizeLoadedPanelState(await loadStoredState());
1196
+ composerDraftEditedHere = false;
1197
+ await getRunReviewController().recoverInterruptedUndoStatuses();
1097
1198
  initializeRunQueueScheduler();
1098
1199
  recoverInterruptedRunJournals()
1099
1200
  .then(() => applyStateToPanel())
@@ -1165,7 +1266,8 @@
1165
1266
  version: CodexOverleafCompatibility?.BUILD_TARGET_VERSION ||
1166
1267
  chrome.runtime.getManifest?.().version ||
1167
1268
  '',
1168
- extensionId: getCurrentExtensionId()
1269
+ extensionId: getCurrentExtensionId(),
1270
+ installIdentity: { managed: MANAGED_INSTALLATION }
1169
1271
  };
1170
1272
  }
1171
1273
 
@@ -1197,7 +1299,6 @@
1197
1299
  callbacks: {
1198
1300
  onLauncherToggle: () => ensurePanelOpen(),
1199
1301
  onClosePanel: () => closePanel(),
1200
- onRefresh: () => refreshProbe({ userInitiated: true }),
1201
1302
  onNewSession: () => startNewSession(),
1202
1303
  onChangeHistory: () => openChangeHistory(),
1203
1304
  onSettingsClick: () => toggleCustomInstructionsSettings(),
@@ -1219,7 +1320,9 @@
1219
1320
  diagnosticsPanelInstance = DiagnosticsPanel.create({
1220
1321
  container: panelRendererInstance.diagnosticsSlot,
1221
1322
  i18n: { tr },
1323
+ icons: Modules.PanelIcons,
1222
1324
  callbacks: {
1325
+ onRefresh: () => refreshProbe({ userInitiated: true }),
1223
1326
  onBeforeOpen: () => {
1224
1327
  closeModelConfigPopover();
1225
1328
  closeContextTray();
@@ -1257,6 +1360,7 @@
1257
1360
  projectId: getCurrentProjectId(),
1258
1361
  i18n: { tr },
1259
1362
  button: panel.querySelector('[data-custom-instructions-settings]'),
1363
+ getProjectName: () => { try { return recentProjects.readCurrentProjectNameFromDom(); } catch { return ''; } },
1260
1364
  callbacks: {
1261
1365
  onBack: () => closeCustomInstructionsSettings(),
1262
1366
  onInputChange: event => persistPanelInputs(event),
@@ -1297,9 +1401,12 @@
1297
1401
  composerPanelInstance = ComposerPanel.create({
1298
1402
  container: panelRendererInstance.composerSlot,
1299
1403
  i18n: { tr },
1404
+ icons: Modules.PanelIcons,
1300
1405
  attachmentController: composerAttachmentController,
1301
1406
  callbacks: {
1302
1407
  onSubmit: () => safeRunTask(),
1408
+ getTaskScope: getComposerDraftScope,
1409
+ getTaskValue: () => state.task,
1303
1410
  isRunning: () => Boolean(currentRunView),
1304
1411
  onCancel: () => cancelActiveRun(),
1305
1412
  onPaste: handleComposerPaste,
@@ -1309,12 +1416,13 @@
1309
1416
  onTaskKeydown: handleTaskInputKeydown,
1310
1417
  onTaskInput: handleTaskInput,
1311
1418
  onSlashMenuClick: handleSlashMenuClick,
1419
+ onMenuLayoutChange: updateSlashMenuLayout,
1312
1420
  onClearSkillInvocation: clearComposerSkillInvocation,
1313
1421
  onAddContext: toggleContextTray,
1314
1422
  onAttachClick: () => panel.querySelector('[data-attach-input]')?.click(),
1315
1423
  onAttachInput: handleAttachInputChange,
1316
1424
  onContextRefresh: () => loadContextFiles({ force: true }),
1317
- onModelConfigToggle: () => toggleModelConfigPopover(),
1425
+ onModelConfigToggle: () => { contextUsageView?.close(); toggleModelConfigPopover(); },
1318
1426
  onModelConfigChoiceClick: event => {
1319
1427
  handleModelConfigChoiceClick(event).catch(error => {
1320
1428
  appendPlainLog(tx(`Failed to update model settings: ${error.message}`, `更新模型设置失败:${error.message}`));
@@ -1327,6 +1435,18 @@
1327
1435
  }
1328
1436
  });
1329
1437
 
1438
+ contextUsageView = Modules.ContextUsageView.create({
1439
+ getPanel: () => panel, getLocale, tr,
1440
+ onOpen: () => {
1441
+ closeModelConfigPopover();
1442
+ closeContextTray();
1443
+ closeSlashMenu();
1444
+ },
1445
+ getCompactAvailability,
1446
+ level: Modules.ContextUsage.level,
1447
+ onCompact: () => safeRunTask({ operation: 'compact' })
1448
+ });
1449
+ renderContextUsage();
1330
1450
  installContextDismiss();
1331
1451
  installModelConfigDismiss();
1332
1452
  bindLogAutoFollow();
@@ -1335,34 +1455,87 @@
1335
1455
  renderContextSelection();
1336
1456
  scheduleMirrorPrefetch({ reason: 'cold-start', delayMs: 2500 });
1337
1457
  primeCodexOverleafSkillsCatalog().catch(() => { /* non-blocking */ });
1458
+ primeCodexLocalSkillsCatalog().catch(() => { /* retry through settings, slash, or submission */ });
1338
1459
  }
1339
1460
 
1340
1461
  PanelRenderer.setVisible(panel, true);
1341
1462
  }
1342
1463
 
1343
- // One-shot catalog prime: the parallel-subagents broker gate reads
1344
- // state.codexOverleafSkills at run time. The catalog persists once filled
1345
- // (v1.6.1), but states saved by older builds have it stripped — without
1346
- // this prime, the gate stays silently off until the user happens to open
1347
- // the Skills page. Fire-and-forget on panel open; only fetches when the
1348
- // catalog is empty.
1349
- async function primeCodexOverleafSkillsCatalog() {
1464
+ // Panel prefetch and first submission share the same official catalog read.
1465
+ // A required read fails through the existing task-start error presentation.
1466
+ let codexOverleafSkillsCatalogPromise = null;
1467
+ async function primeCodexOverleafSkillsCatalog({ required = false } = {}) {
1350
1468
  if (Array.isArray(state?.codexOverleafSkills) && state.codexOverleafSkills.length) {
1351
- return;
1352
- }
1353
- if (getSkillLoadingSettings().loadCodexOverleafSkills === false) {
1354
- return;
1355
- }
1356
- const response = await sendBackgroundNative({
1357
- method: 'skills.list',
1358
- params: { scope: 'codex-overleaf' }
1469
+ return state.codexOverleafSkills;
1470
+ }
1471
+ if (!required && getSkillLoadingSettings().loadCodexOverleafSkills === false) return [];
1472
+ if (!codexOverleafSkillsCatalogPromise) {
1473
+ codexOverleafSkillsCatalogPromise = sendBackgroundNative({
1474
+ method: 'skills.list', params: { scope: 'codex-overleaf' }
1475
+ }).then(response => {
1476
+ const skills = Array.isArray(response?.result?.skills) ? response.result.skills : [];
1477
+ if (!response?.ok || !skills.length) throw new Error(tx(
1478
+ 'Could not load Codex Overleaf skills. Try again.', '无法加载 Codex Overleaf 技能,请重试。'));
1479
+ state = { ...state, codexOverleafSkills: skills };
1480
+ saveStateSoon();
1481
+ return skills;
1482
+ }).finally(() => { codexOverleafSkillsCatalogPromise = null; });
1483
+ }
1484
+ return codexOverleafSkillsCatalogPromise;
1485
+ }
1486
+
1487
+ // Local metadata stays in memory. A stale page/account read cannot become the
1488
+ // next page's selected catalog, and an empty successful directory is valid.
1489
+ let codexLocalSkillsCatalog = null;
1490
+ let codexLocalSkillsWarnings = [];
1491
+ let codexLocalSkillsCatalogScope = '';
1492
+ let codexLocalSkillsCatalogRequest = null;
1493
+ function getCodexLocalSkillsCatalogScope() {
1494
+ return [cachedAccountScopeId, getCurrentProjectId(), spaRouteGeneration].join(':');
1495
+ }
1496
+
1497
+ function getCodexLocalSkillsCatalog() {
1498
+ return codexLocalSkillsCatalogScope === getCodexLocalSkillsCatalogScope() ? codexLocalSkillsCatalog : null;
1499
+ }
1500
+
1501
+ function getCodexLocalSkillWarnings() {
1502
+ return codexLocalSkillsCatalogScope === getCodexLocalSkillsCatalogScope() ? codexLocalSkillsWarnings : [];
1503
+ }
1504
+
1505
+ async function primeCodexLocalSkillsCatalog({ required = false, force = false } = {}) {
1506
+ const scope = getCodexLocalSkillsCatalogScope();
1507
+ if (!required && !force && getSkillLoadingSettings().loadCodexLocalSkills === false) return [];
1508
+ if (!force && codexLocalSkillsCatalogScope === scope && Array.isArray(codexLocalSkillsCatalog)) return codexLocalSkillsCatalog;
1509
+ if (codexLocalSkillsCatalogRequest?.scope === scope) return codexLocalSkillsCatalogRequest.promise;
1510
+ const request = { scope, promise: null };
1511
+ request.promise = sendBackgroundNative({ method: 'skills.list', params: { scope: 'codex-local' } }).then(response => {
1512
+ if (!response?.ok || !Array.isArray(response.result?.skills)) throw new Error(tx(
1513
+ 'Could not load local Codex skills. Try again.', '无法加载 Codex 本地技能,请重试。'));
1514
+ if (scope !== getCodexLocalSkillsCatalogScope()) throw new Error(tx(
1515
+ 'The active project changed while loading skills. Try again.', '加载技能时活动项目已改变,请重试。'));
1516
+ const seen = new Set();
1517
+ const skills = response.result.skills.filter(skill => {
1518
+ const id = String(skill?.id || '');
1519
+ if (!isSafeSkillId(id) || seen.has(id)) return false;
1520
+ seen.add(id); return true;
1521
+ });
1522
+ codexLocalSkillsCatalog = skills;
1523
+ codexLocalSkillsWarnings = (Array.isArray(response.result.discoveryWarnings) ? response.result.discoveryWarnings : [])
1524
+ .filter(warning => warning?.code === 'codex_local_skills_skipped' && Number.isSafeInteger(warning.count) && warning.count > 0)
1525
+ .map(warning => ({ code: warning.code, count: warning.count }));
1526
+ codexLocalSkillsCatalogScope = scope;
1527
+ updateSkillsEntrySummary();
1528
+ updateSlashMenuForTaskInput();
1529
+ return skills;
1530
+ }).catch(() => {
1531
+ if (scope !== getCodexLocalSkillsCatalogScope()) throw new Error(tx(
1532
+ 'The active project changed while loading skills. Try again.', '加载技能时活动项目已改变,请重试。'));
1533
+ throw new Error(tx('Could not load local Codex skills. Try again.', '无法加载 Codex 本地技能,请重试。'));
1534
+ }).finally(() => {
1535
+ if (codexLocalSkillsCatalogRequest === request) codexLocalSkillsCatalogRequest = null;
1359
1536
  });
1360
- const skills = Array.isArray(response?.result?.skills) ? response.result.skills : [];
1361
- if (!response?.ok || !skills.length) {
1362
- return;
1363
- }
1364
- state = { ...state, codexOverleafSkills: skills };
1365
- saveStateSoon();
1537
+ codexLocalSkillsCatalogRequest = request;
1538
+ return request.promise;
1366
1539
  }
1367
1540
 
1368
1541
  function closePanel() {
@@ -1445,9 +1618,13 @@
1445
1618
  panel.querySelectorAll('[data-i18n]').forEach(element => {
1446
1619
  element.textContent = tr(element.dataset.i18n);
1447
1620
  });
1621
+ panel.querySelectorAll('[data-i18n-aria-label]').forEach(element => {
1622
+ element.setAttribute('aria-label', tr(element.dataset.i18nAriaLabel));
1623
+ });
1624
+ panelRendererInstance?.setLocale?.({ launcher: tx('Show or hide Codex panel', '显示或隐藏 Codex 边栏'), close: tx('Close Codex panel', '关闭 Codex 边栏') });
1448
1625
 
1449
1626
  setElementTitleAndAria('[data-panel-resize-handle]', tr('resizePanel'), tr('resizePanel'));
1450
- setElementTitleAndAria('[data-refresh]', tr('refreshProbe'), tr('refreshProbe'));
1627
+ setRefreshProbeLoading(panel?.querySelector('[data-refresh]')?.dataset.loading === 'true');
1451
1628
  // State-dependent tooltip: re-derive from the dot's current health so a
1452
1629
  // locale/session refresh doesn't clobber it back to the generic label.
1453
1630
  setDiagnosticsHealth(panel.querySelector('[data-diagnostics-health-dot]')?.dataset.health || 'unknown');
@@ -1498,7 +1675,7 @@
1498
1675
  }
1499
1676
  const reviewToggle = panel.querySelector('.codex-review-toggle');
1500
1677
  if (reviewToggle) {
1501
- reviewToggle.title = tr('requireReviewingTitle');
1678
+ syncReviewingTooltip();
1502
1679
  }
1503
1680
  const recompileToggle = panel.querySelector('.codex-recompile-toggle');
1504
1681
  if (recompileToggle) {
@@ -1514,18 +1691,32 @@
1514
1691
  contextStatus.textContent = tr('contextStatus');
1515
1692
  }
1516
1693
  // Re-render rather than patch text: the first div is the animated mark.
1517
- if (panel.querySelector('.empty-runs')) renderRunHistory();
1694
+ if (arguments[0]?.reprojectDynamic !== false) runTimelineView.refreshLocale?.();
1518
1695
 
1519
1696
  syncModeControls();
1520
1697
  updateOtStatusDisplay();
1521
1698
  updateExperimentalOtMenuStatus();
1522
1699
  renderModelConfigChoices();
1523
1700
  updateModelDisplay();
1701
+ // The header switcher's title and accessible name carry localized text too.
1702
+ applySessionLabel();
1524
1703
  renderSessionList();
1704
+ renderContextUsage();
1705
+ if (panel.querySelector('[data-slash-menu]')?.hidden === false) updateSlashMenuForTaskInput();
1525
1706
  renderContextSelection();
1707
+ renderContextSummary();
1708
+ renderPendingInputs();
1709
+ renderComposerAttachments();
1710
+ renderComposerSkillInvocation();
1526
1711
  writingStyleSettings.sync();
1527
1712
  updateSkillsEntrySummary();
1713
+ localSkillsPanel.refreshLocale?.();
1528
1714
  providerSettingsCoordinator.renderSummary();
1715
+ providerSettingsCoordinator.refreshLocale?.();
1716
+ settingsPanelInstance?.refreshLocale?.();
1717
+ Modules.UpdateNotice?.refreshLocale?.();
1718
+ DiagnosticsPanel.refreshLocale?.(diagnosticsPanelInstance, diagnosticsController.refreshLocale?.());
1719
+ if (!panel.querySelector('[data-context-tray]')?.hidden) renderContextFiles();
1529
1720
  if (settingsPanelInstance?.container?.querySelector('[data-storage-card]')?.open) refreshStorageUsageSummary();
1530
1721
  }
1531
1722
 
@@ -1685,47 +1876,81 @@
1685
1876
  function resetContextProject() { return contextTrayController.resetContextProject(); }
1686
1877
 
1687
1878
  async function runTask(options = {}) {
1688
- // Barrier on a still-flying background mirror refresh from the previous
1689
- // turn (v1.7.5) BEFORE startRunView creates the new run view: the mirror's
1690
- // wrap-up events must land in the run that produced them, not the new
1691
- // one, and mirror.sync landing mid-run would swap the local baseline
1692
- // under Codex.
1693
- const pendingMirror = getPendingMirrorRefresh?.();
1694
- if (pendingMirror) {
1695
- await pendingMirror.catch(() => {});
1696
- }
1879
+ // Capture the click before any preparation can yield to another draft/session.
1697
1880
  readPanelInputs();
1698
1881
  const queuedInput = options.queuedInput || null;
1699
- const task = String(queuedInput?.text || state.task || '').trim();
1882
+ const compact = !queuedInput && options.operation === 'compact';
1883
+ const task = compact ? '/compact' : String(queuedInput?.text || state.task || '').trim();
1700
1884
  if (!task) {
1701
1885
  appendLog(tx('Enter a task first.', '请先输入任务。'));
1702
1886
  return;
1703
1887
  }
1888
+ const submittedSessionId = state.activeSessionId;
1889
+ const submittedSession = runController.captureSessionContext(state);
1890
+ const submittedThreadId = getActiveSession(state)?.codexThreadId || '';
1891
+ if (compact && !submittedThreadId) {
1892
+ showPluginToast(tr('compactNoThread'), { status: 'info' });
1893
+ return;
1894
+ }
1895
+ const submittedDraft = state.task;
1896
+ const submittedRetryReplacement = pendingRetryReplacement;
1704
1897
  const submittedWritingStyleScope = { accountScopeId: cachedAccountScopeId, projectId: getCurrentProjectId() };
1705
1898
  const submittedPanelState = {
1706
- mode: state.mode,
1899
+ mode: compact ? 'ask' : state.mode,
1707
1900
  providerId: state.providerId,
1708
1901
  model: state.model,
1709
1902
  reasoningEffort: state.reasoningEffort,
1710
1903
  speedTier: state.speedTier,
1711
1904
  autoRecompile: state.autoRecompile !== false,
1712
1905
  requireReviewing: state.requireReviewing === true,
1713
- focusFiles: getActiveFocusFiles(),
1714
- selectionContext: selectionContextView.capture()
1906
+ focusFiles: compact ? [] : getActiveFocusFiles(),
1907
+ selectionContext: compact ? null : selectionContextView.capture()
1715
1908
  };
1716
- const submittedCustomInstructions = getCustomInstructionsForCurrentProject();
1717
- const submittedSkillLoadingSettings = getSkillLoadingSettings();
1718
- const submittedAttachments = getComposerAttachmentsForRun();
1719
- const submittedSkillInvocation = getComposerSkillInvocationForRun();
1909
+ const queuedContext = queuedInput
1910
+ ? RunInputQueue.normalizeSubmissionContext(queuedInput.payload?.submissionContext) : null;
1911
+ const submittedCustomInstructions = queuedContext ? queuedContext.customInstructions : getCustomInstructionsForCurrentProject();
1912
+ const submittedSkillLoadingSettings = queuedContext ? queuedContext.skillLoadingSettings : getSkillLoadingSettings();
1913
+ const submittedAttachments = queuedInput || compact ? [] : getComposerAttachmentsForRun();
1914
+ const submittedSkillInvocation = compact ? null : queuedContext ? queuedContext.skillInvocation : getComposerSkillInvocationForRun();
1915
+ let submittedEnabledSkillIds = queuedContext ? queuedContext.enabledCodexOverleafSkillIds : getEnabledCodexOverleafSkillIds();
1916
+ const submittedSkillEnabled = { ...state.codexOverleafSkillEnabled };
1917
+ const submittedLocalSkillEnabled = { ...state.codexLocalSkillEnabled };
1918
+ const submittedSelectionRevision = selectionContextView.getRevision?.();
1919
+ let submittedEnabledLocalSkillIds = queuedContext ? queuedContext.enabledCodexLocalSkillIds : compact ? getEnabledCodexLocalSkillIds() : [];
1920
+ // Capture submission first; a save must not adopt text typed while it waits.
1921
+ if (!queuedInput) await flushComposerDraft({ requireDurable: true });
1922
+ if (!compact && !queuedInput && submittedSkillLoadingSettings.loadCodexLocalSkills !== false) {
1923
+ const localSkills = await primeCodexLocalSkillsCatalog({ required: true, force: submittedSkillInvocation?.scope === 'codex-local' });
1924
+ submittedEnabledLocalSkillIds = localSkills.filter(skill => Object.hasOwn(submittedLocalSkillEnabled, skill.id)
1925
+ ? submittedLocalSkillEnabled[skill.id] !== false : skill.enabled !== false).map(skill => skill.id);
1926
+ }
1927
+ if (!queuedInput && submittedSkillInvocation?.scope === 'codex-local'
1928
+ && (submittedSkillLoadingSettings.loadCodexLocalSkills === false || !submittedEnabledLocalSkillIds.includes(submittedSkillInvocation.id))) {
1929
+ throw new Error(tx('The selected local Codex skill is unavailable or disabled. Select an enabled skill and try again.',
1930
+ '所选 Codex 本地技能不可用或已关闭,请选择已启用的技能后重试。'));
1931
+ }
1932
+ if (!compact && !queuedInput && submittedSkillLoadingSettings.loadCodexOverleafSkills !== false) {
1933
+ const skills = await primeCodexOverleafSkillsCatalog({ required: true });
1934
+ submittedEnabledSkillIds = skills.map(skill => String(skill?.id || '').trim())
1935
+ .filter(id => id && submittedSkillEnabled[id] !== false);
1936
+ }
1937
+ // Keep the previous mirror's completion events on its own run.
1938
+ const pendingMirror = getPendingMirrorRefresh?.();
1939
+ if (pendingMirror) await pendingMirror.catch(() => {});
1720
1940
  await providerSettingsCoordinator.ensureLoaded();
1721
- if (!queuedInput) await writingStyleSettings.ensureLoaded(true);
1941
+ if (!queuedInput && !compact) await writingStyleSettings.ensureLoaded(true);
1722
1942
  if (submittedWritingStyleScope.accountScopeId !== cachedAccountScopeId
1723
1943
  || submittedWritingStyleScope.projectId !== getCurrentProjectId()) {
1724
1944
  throw new Error(tx('The active project changed before submission. Submit the task again.',
1725
1945
  '提交前活动项目已改变,请重新提交任务。'));
1726
1946
  }
1727
- submittedPanelState.writingStyle = queuedInput ? null : writingStyleSettings.capture();
1728
- const executionSnapshot = queuedInput
1947
+ if (!findSessionById(submittedSessionId) || currentRunView
1948
+ || (compact && findSessionById(submittedSessionId)?.codexThreadId !== submittedThreadId)) {
1949
+ throw new Error(tx('The submitted session changed before submission. Submit the task again.',
1950
+ '提交所属会话已改变,请重新提交任务。'));
1951
+ }
1952
+ submittedPanelState.writingStyle = queuedInput || compact ? null : writingStyleSettings.capture();
1953
+ let executionSnapshot = queuedInput
1729
1954
  ? RunExecutionSnapshot.resolveForExecution(
1730
1955
  RunExecutionSnapshot.fromQueuePayload(queuedInput.payload),
1731
1956
  { catalog: providerSettingsCoordinator.getCatalog() }
@@ -1733,33 +1958,89 @@
1733
1958
  : captureCurrentExecutionSnapshot(submittedPanelState);
1734
1959
  // Freeze submitted run identity before the panel can change during the run.
1735
1960
  const submittedMode = executionSnapshot.mode;
1736
- const submittedRequireReviewing = executionSnapshot.requireReviewing === true;
1737
- const replaceRunId = queuedInput ? '' : runController.resolveRetryReplacement({
1738
- candidate: pendingRetryReplacement,
1739
- activeSessionId: state.activeSessionId,
1740
- runs: state.runs,
1961
+ let submittedRequireReviewing = executionSnapshot.requireReviewing === true;
1962
+ const replaceRunId = queuedInput || compact ? '' : runController.resolveRetryReplacement({
1963
+ candidate: submittedRetryReplacement,
1964
+ activeSessionId: submittedSessionId,
1965
+ runs: findSessionById(submittedSessionId)?.runs,
1741
1966
  projectRunSettlement: run => WritebackSettlement.projectRunSettlement(run)
1742
1967
  });
1743
1968
 
1744
1969
  runCancellationRequested = false;
1745
1970
  runCancellationController = new AbortController();
1746
1971
  setRunning(true);
1747
- currentRunView = startRunView({
1748
- task,
1749
- mode: submittedMode,
1750
- model: executionSnapshot.model,
1751
- reasoningEffort: executionSnapshot.reasoningEffort,
1752
- speedTier: executionSnapshot.speedTier,
1753
- executionSnapshot,
1754
- attachments: submittedAttachments,
1755
- skillInvocation: submittedSkillInvocation,
1756
- queueItemId: queuedInput?.id || '',
1757
- replaceRunId
1758
- });
1759
- if (!queuedInput) pendingRetryReplacement = null;
1760
- const runSessionId = currentRunView.sessionId;
1972
+ const runSessionId = submittedSessionId;
1761
1973
  let runAuditDraft = null;
1974
+ let localSkillRestorePoint = null;
1762
1975
  try {
1976
+ currentRunView = startRunView({
1977
+ sessionId: submittedSessionId,
1978
+ sessionContext: submittedSession,
1979
+ codexThreadId: submittedThreadId,
1980
+ operation: compact ? 'compact' : undefined,
1981
+ task,
1982
+ mode: submittedMode,
1983
+ model: executionSnapshot.model,
1984
+ reasoningEffort: executionSnapshot.reasoningEffort,
1985
+ speedTier: executionSnapshot.speedTier,
1986
+ executionSnapshot,
1987
+ attachments: submittedAttachments,
1988
+ skillInvocation: submittedSkillInvocation,
1989
+ queueItemId: queuedInput?.id || '',
1990
+ replaceRunId
1991
+ });
1992
+ // Preparation can await project/audit work before a Native request exists.
1993
+ // Give other pages a live owner to check throughout that interval.
1994
+ const bound = await awaitRunStep(activeTurnControl.bindBrowserRun({
1995
+ requestId: ActiveTurnControl.getStartupRequestId(runSessionId, currentRunView.recordId),
1996
+ projectKey: currentRunView.runProjectId, clientRunId: currentRunView.recordId,
1997
+ sessionId: runSessionId, ownerToken: crypto.randomUUID()
1998
+ }));
1999
+ throwIfRunCancellationRequested();
2000
+ if (!bound) throw new Error(tx('Could not start the task. Reload the page and try again.',
2001
+ '无法启动任务,请刷新页面后重试。'));
2002
+ renderContextUsage();
2003
+ if (!queuedInput && !compact && pendingRetryReplacement === submittedRetryReplacement) pendingRetryReplacement = null;
2004
+ if (compact) {
2005
+ announceCrossTabRunStart();
2006
+ // Only the slash-command text is consumed; a draft kept while compacting from the popover stays.
2007
+ if (options.consumeCompactDraft === true && state.activeSessionId === submittedSessionId
2008
+ && /^\/[^\s/]*$/.test(String(submittedDraft || '').trim())
2009
+ && panel?.querySelector('[data-task]')?.value === submittedDraft) {
2010
+ panel.querySelector('[data-task]').value = '';
2011
+ updateSessionById(submittedSessionId, { task: '' });
2012
+ recordSubmittedDraftConsumption(submittedSessionId, submittedDraft);
2013
+ composerDraftEditedHere = false;
2014
+ composerDraftRevision += 1;
2015
+ autosizeTaskTextarea();
2016
+ closeSlashMenu();
2017
+ syncComposerSendAvailability(true);
2018
+ }
2019
+ appendRunEvent({ title: tr('contextUsageCompacting'), status: 'running',
2020
+ activity: { v: 1, kind: 'lifecycle', state: 'running', target: 'context_compaction' } });
2021
+ renderContextUsage();
2022
+ const response = await awaitRunStep(sendTrackedCodexRun({
2023
+ operation: 'compact', task: '/compact', mode: 'ask',
2024
+ projectId: currentRunView.runProjectId, accountScopeId: currentRunView.runAccountScopeId,
2025
+ clientRunId: currentRunView.recordId, clientSessionId: runSessionId,
2026
+ threadId: submittedThreadId, model: executionSnapshot.model,
2027
+ reasoningEffort: executionSnapshot.reasoningEffort, speedTier: executionSnapshot.speedTier,
2028
+ providerSelection: RunExecutionSnapshot.toProviderSelection(executionSnapshot),
2029
+ locale: getLocale(), ...submittedSkillLoadingSettings,
2030
+ enabledCodexOverleafSkillIds: submittedEnabledSkillIds,
2031
+ enabledCodexLocalSkillIds: submittedEnabledLocalSkillIds
2032
+ }));
2033
+ if (!response?.ok) throw Object.assign(new Error(response?.error?.message || 'Compaction failed'), response?.error);
2034
+ if (response.result?.operation !== 'compact' || response.result?.status !== 'completed'
2035
+ || response.result?.threadId !== submittedThreadId) {
2036
+ throw Object.assign(new Error('Compaction completion was not confirmed'), { code: 'codex_compact_unconfirmed' });
2037
+ }
2038
+ throwIfRunCancellationRequested();
2039
+ appendCompletionReport({ conclusion: tr('compactCompleted'), conclusionKey: 'context_compacted',
2040
+ status: 'completed', mode: 'ask', operations: [], applyResults: [] });
2041
+ await finishRunView(tr('compactCompleted'), 'completed');
2042
+ return;
2043
+ }
1763
2044
  if (queuedInput?.id) {
1764
2045
  await runQueueScheduler?.markExecuting(runSessionId, queuedInput.id, currentRunView.recordId);
1765
2046
  }
@@ -1770,14 +2051,32 @@
1770
2051
  focusFiles: executionSnapshot.focusFiles,
1771
2052
  executionSnapshot
1772
2053
  }));
2054
+ if (submittedWritingStyleScope.accountScopeId !== cachedAccountScopeId
2055
+ || submittedWritingStyleScope.projectId !== getCurrentProjectId()) {
2056
+ throw Object.assign(createRunCancellationError(), {
2057
+ message: 'The active project or account changed before dispatch.'
2058
+ });
2059
+ }
1773
2060
  announceCrossTabRunStart();
1774
2061
  // Binary attachments belong to this submitted turn. The run record has
1775
2062
  // already captured their snapshots, so clear them from the composer
1776
2063
  // together with the submitted text. Persistent @context remains intact.
1777
- clearTaskComposer({ keepAttachments: false });
2064
+ const submittedComposerCleared = !queuedInput && clearTaskComposer({ keepAttachments: false, submission: {
2065
+ ...submittedWritingStyleScope,
2066
+ sessionId: submittedSessionId, task: submittedDraft, attachments: submittedAttachments,
2067
+ skillInvocation: submittedSkillInvocation, selectionContext: submittedPanelState.selectionContext,
2068
+ selectionRevision: submittedSelectionRevision
2069
+ } });
2070
+ if (submittedComposerCleared && submittedSkillInvocation?.scope === 'codex-local'
2071
+ && state.activeSessionId === submittedSessionId && !panel?.querySelector('[data-task]')?.value
2072
+ && !getComposerAttachmentsForRun().length && !composerAttachmentController.getAttachmentByteUsage?.().pending
2073
+ && !getComposerSkillInvocationForRun() && !selectionContextView.capture()) {
2074
+ localSkillRestorePoint = { draftRevision: composerDraftRevision,
2075
+ attachmentRevision: composerAttachmentController.getRevision?.(), selectionRevision: selectionContextView.getRevision?.() };
2076
+ }
1778
2077
  syncComposerSendAvailability(true);
1779
2078
  appendRunEvent({
1780
- title: submittedSkillInvocation?.id === 'skill-installer'
2079
+ title: submittedSkillInvocation?.id === 'skill-installer' && !submittedSkillInvocation.scope
1781
2080
  ? tx('I will use the Codex skill installer for this request.', '我会用 Codex skill installer 处理这个请求。')
1782
2081
  : tx('I will first understand your request, then inspect the relevant Overleaf files.', '我会先理解你的请求,再检查相关 Overleaf 文件。'),
1783
2082
  status: 'running',
@@ -1792,7 +2091,7 @@
1792
2091
  '@context': formatContextItems(executionSnapshot.focusFiles)
1793
2092
  }
1794
2093
  });
1795
- if (submittedSkillInvocation?.id === 'skill-installer') {
2094
+ if (submittedSkillInvocation?.id === 'skill-installer' && !submittedSkillInvocation.scope) {
1796
2095
  await awaitRunStep(runSkillInstallerTask({
1797
2096
  task,
1798
2097
  runSessionId,
@@ -1800,6 +2099,8 @@
1800
2099
  submittedMode,
1801
2100
  submittedCustomInstructions,
1802
2101
  submittedSkillLoadingSettings,
2102
+ submittedEnabledLocalSkillIds,
2103
+ submittedEnabledSkillIds,
1803
2104
  submittedAttachments,
1804
2105
  submittedSkillInvocation,
1805
2106
  executionSnapshot
@@ -1818,7 +2119,30 @@
1818
2119
  await awaitRunStep(pauseOtWarmMirror('run-start'));
1819
2120
  const writeSafety = await awaitRunStep(preflightWriteSafety({
1820
2121
  mode: submittedMode,
1821
- requireReviewing: submittedRequireReviewing
2122
+ requireReviewing: submittedRequireReviewing,
2123
+ onUntracked: async choice => {
2124
+ submittedRequireReviewing = false;
2125
+ // Keep the effective policy identical in dispatch, recovery and Undo consumers.
2126
+ const view = currentRunView;
2127
+ const record = findRunRecord(view.recordId, view.sessionId);
2128
+ executionSnapshot = RunExecutionSnapshot.applyUntrackedDecision(view, record, choice);
2129
+ if (choice === 'disable') {
2130
+ updateSessionById(view.sessionId, { requireReviewing: false });
2131
+ if (state.activeSessionId === view.sessionId) {
2132
+ state.requireReviewing = false;
2133
+ panel.querySelector('[data-require-reviewing]').checked = false;
2134
+ }
2135
+ }
2136
+ appendRunEvent({ title: tr('reviewingUnavailableRunUntracked'), status: 'completed',
2137
+ technicalDetail: { code: 'reviewing_unavailable', submittedRequireReviewing: true,
2138
+ effectiveRequireReviewing: false, choice } });
2139
+ saveStateSoon(0);
2140
+ syncProbeStatus();
2141
+ runAuditDraft = await finalizeAuditDraftPartial(runAuditDraft, {
2142
+ requireReviewing: false, trackingDecision: choice
2143
+ });
2144
+ await flushQueuedSaveState({ requireDurable: true });
2145
+ }
1822
2146
  }));
1823
2147
  if (!writeSafety.ok) {
1824
2148
  await finalizeAuditRecord(runAuditDraft, {
@@ -1860,6 +2184,7 @@
1860
2184
  project,
1861
2185
  snapshotWarnings,
1862
2186
  focusFiles,
2187
+ focusFilesAreLiteral: true,
1863
2188
  isUsableProjectFileContent: Modules.ProjectFiles.isUsableProjectFileContent
1864
2189
  });
1865
2190
  let restrictToFocusFiles = runController.shouldRestrictWritebackToFocus({ focusFiles, focusedPartialSnapshot });
@@ -1879,7 +2204,7 @@
1879
2204
  fileCount: (project?.files || []).length
1880
2205
  }
1881
2206
  });
1882
- appendCompletionReport({
2207
+ appendCompletionReport({ conclusionKey: 'full_project_unread',
1883
2208
  conclusion: tx('This run did not continue: the full Overleaf project was not read.', '这轮没有继续:没有读到完整的 Overleaf 项目内容。'),
1884
2209
  status: 'blocked',
1885
2210
  operations: [],
@@ -1928,8 +2253,7 @@
1928
2253
  }
1929
2254
  }
1930
2255
 
1931
- const activeSession = findSessionById(runSessionId) || getActiveSession(state);
1932
- const codexThreadId = activeSession?.codexThreadId || '';
2256
+ const codexThreadId = submittedThreadId;
1933
2257
 
1934
2258
  // Resolve @compile-log if mentioned in task
1935
2259
  let compileLogContext = null;
@@ -1957,14 +2281,14 @@
1957
2281
  }
1958
2282
  });
1959
2283
  appendRunEvent({
1960
- title: tx(`Compile log unavailable: ${compileLogContext.reason}`, `编译日志不可用:${compileLogContext.reason}`),
2284
+ title: tx(`Compile log unavailable: ${Modules.CompileAdapter.localizeCompileReason(compileLogContext.reason, getLocale())}`, `编译日志不可用:${Modules.CompileAdapter.localizeCompileReason(compileLogContext.reason, getLocale())}`),
1961
2285
  status: 'failed',
1962
2286
  failure: contextFailure
1963
2287
  });
1964
2288
  }
1965
2289
  }
1966
2290
 
1967
- const sensitiveFindings = await awaitRunStep(runSensitivePreflight({
2291
+ let sensitiveFindings = await awaitRunStep(runSensitivePreflight({
1968
2292
  task,
1969
2293
  project,
1970
2294
  rules: getGovernanceRulesForCurrentProject(),
@@ -2001,6 +2325,8 @@
2001
2325
  compileLogContext,
2002
2326
  customInstructions: submittedCustomInstructions,
2003
2327
  skillLoadingSettings: submittedSkillLoadingSettings,
2328
+ enabledCodexOverleafSkillIds: submittedEnabledSkillIds,
2329
+ enabledCodexLocalSkillIds: submittedEnabledLocalSkillIds,
2004
2330
  attachments: submittedAttachments,
2005
2331
  skillInvocation: submittedSkillInvocation,
2006
2332
  submittedMode
@@ -2022,6 +2348,25 @@
2022
2348
  fileOverlays = null;
2023
2349
  otWarmStart = false;
2024
2350
  restrictToFocusFiles = staleRetry.restrictToFocusFiles;
2351
+ const retryFindings = await awaitRunStep(runSensitivePreflight({
2352
+ task, project, rules: getGovernanceRulesForCurrentProject(), useExistingMirror: false
2353
+ }));
2354
+ if (retryFindings.blocked) {
2355
+ await finalizeAuditRecord(runAuditDraft, {
2356
+ resultStatus: 'blocked_sensitive', sensitiveFindings: retryFindings.findings,
2357
+ blockedFiles: retryFindings.findings.map(finding => ({
2358
+ path: finding.path || finding.source || 'task', reason: finding.detectorId || 'sensitive'
2359
+ }))
2360
+ });
2361
+ await finishRunView(tx('Blocked: sensitive content review required', '已阻止:需要处理敏感内容'), 'failed');
2362
+ return;
2363
+ }
2364
+ if (retryFindings.findings.length) {
2365
+ sensitiveFindings = {
2366
+ blocked: false, findings: [...sensitiveFindings.findings, ...retryFindings.findings]
2367
+ };
2368
+ runAuditDraft = await updateAuditSensitiveFindings(runAuditDraft, sensitiveFindings.findings);
2369
+ }
2025
2370
  response = await awaitRunStep(sendTrackedCodexRun(buildCodexRunParams({
2026
2371
  task,
2027
2372
  project,
@@ -2034,6 +2379,8 @@
2034
2379
  compileLogContext,
2035
2380
  customInstructions: submittedCustomInstructions,
2036
2381
  skillLoadingSettings: submittedSkillLoadingSettings,
2382
+ enabledCodexOverleafSkillIds: submittedEnabledSkillIds,
2383
+ enabledCodexLocalSkillIds: submittedEnabledLocalSkillIds,
2037
2384
  attachments: submittedAttachments,
2038
2385
  skillInvocation: submittedSkillInvocation,
2039
2386
  submittedMode
@@ -2044,12 +2391,17 @@
2044
2391
  const userChoice = await showThreadResumeFailedPrompt();
2045
2392
  if (userChoice === 'new') {
2046
2393
  updateSessionById(runSessionId, { codexThreadId: '' });
2394
+ renderContextUsage();
2047
2395
  const StorageDb = Modules.StorageDb;
2048
- if (StorageDb) {
2049
- const record = await StorageDb.getRecord('sessions', runSessionId);
2396
+ // A live session goes through the ordinary writer. An envelope
2397
+ // conflict there must not abort the run that is choosing a new thread.
2398
+ if (findSessionById(runSessionId)) {
2399
+ saveStateSoon();
2400
+ } else if (StorageDb) {
2401
+ const record = await StorageDb.getRecord('sessions', runSessionId, { adopt: false });
2050
2402
  if (record) {
2051
2403
  record.codexThreadId = '';
2052
- await StorageDb.putRecord('sessions', record);
2404
+ await StorageDb.putRecord('sessions', record, { adopt: false });
2053
2405
  }
2054
2406
  }
2055
2407
  appendRunEvent({ title: tx('Creating a new Codex conversation thread.', '正在新建 Codex 会话线程。'), status: 'running' });
@@ -2065,6 +2417,8 @@
2065
2417
  compileLogContext,
2066
2418
  customInstructions: submittedCustomInstructions,
2067
2419
  skillLoadingSettings: submittedSkillLoadingSettings,
2420
+ enabledCodexOverleafSkillIds: submittedEnabledSkillIds,
2421
+ enabledCodexLocalSkillIds: submittedEnabledLocalSkillIds,
2068
2422
  attachments: submittedAttachments,
2069
2423
  skillInvocation: submittedSkillInvocation,
2070
2424
  submittedMode
@@ -2085,19 +2439,33 @@
2085
2439
  void finalizeAuditRecord(runAuditDraft, { resultStatus: 'cancelled' });
2086
2440
  return;
2087
2441
  }
2088
- const translated = translateRawError(response.error.message, { mode: submittedMode, locale: getLocale() });
2442
+ if (response.error?.code === 'codex_local_skill_unavailable' && localSkillRestorePoint) {
2443
+ restoreRejectedLocalSkillSubmission({ ...submittedWritingStyleScope, sessionId: submittedSessionId,
2444
+ task: submittedDraft, attachments: submittedAttachments, skillInvocation: submittedSkillInvocation,
2445
+ selectionContext: submittedPanelState.selectionContext }, localSkillRestorePoint);
2446
+ }
2447
+ const translated = translateRawError(response.error.message, { mode: submittedMode, locale: getLocale(), code: response.error?.code });
2089
2448
  // Structured FailureReason (§9.7): split bridge-availability from
2090
2449
  // Codex-side errors. `native_connection_failed` / `native_unavailable`
2091
2450
  // / `native_update_required` indicate the bridge itself is missing,
2092
- // so emit `native_bridge_unavailable` (blocked, retryable). All other
2093
- // native errors are treated as a Codex-side surface error and emit
2094
- // `codex_no_usable_result` (error, retryable).
2451
+ // so emit `native_bridge_unavailable` (blocked, retryable). Recognized
2452
+ // Codex failures retain their specific cause; only unmatched errors
2453
+ // fall back to `codex_no_usable_result` (error, retryable).
2095
2454
  // Map native/codex failures onto their SPECIFIC catalog codes — the
2096
2455
  // timeout/not-found/output-limit entries (and their bilingual next
2097
2456
  // steps) existed but were never emitted, so everything collapsed into
2098
2457
  // "no usable result" and pointed users at the wrong recovery.
2099
2458
  const runErrorMessage = String(response.error?.message || '');
2100
- const codexRunFailure = response.error?.code === 'native_execution_interrupted'
2459
+ const codexRunFailure = translated.failureCode === 'codex_context_limit'
2460
+ ? buildContentFailure('codex_context_limit', { path: 'codex.run' }, {
2461
+ technicalMessage: runErrorMessage,
2462
+ evidence: { errorCode: response.error?.code || '' }
2463
+ })
2464
+ : response.error?.code === 'codex_local_skill_unavailable'
2465
+ ? buildContentFailure('codex_local_skill_unavailable', { path: 'codex.run' }, { technicalMessage: runErrorMessage })
2466
+ : translated.failureCode === 'codex_local_skills_failed'
2467
+ ? buildContentFailure('codex_local_skills_failed', { path: 'codex.run' }, { technicalMessage: runErrorMessage })
2468
+ : response.error?.code === 'native_execution_interrupted'
2101
2469
  ? buildContentFailure('native_request_failed', { path: 'codex.run' }, {
2102
2470
  // The host IS installed — the request was interrupted mid-flight.
2103
2471
  // Telling this user to reinstall would be a misdiagnosis.
@@ -2154,7 +2522,7 @@
2154
2522
  detail: response.error
2155
2523
  });
2156
2524
  appendCompletionReport({
2157
- conclusion: translated.conclusion,
2525
+ conclusion: translated.conclusion, conclusionKey: translated.conclusionKey,
2158
2526
  status: response.error?.code === 'project_locked' ? 'blocked' : 'failed',
2159
2527
  operations: [],
2160
2528
  applyResults: [],
@@ -2204,7 +2572,7 @@
2204
2572
  status: 'failed',
2205
2573
  failure: emptyFailure
2206
2574
  });
2207
- appendCompletionReport({
2575
+ appendCompletionReport({ conclusionKey: 'empty_result',
2208
2576
  conclusion: emptyResultConclusion,
2209
2577
  status: 'failed',
2210
2578
  operations: [],
@@ -2308,12 +2676,14 @@
2308
2676
  if (returnedThreadId && returnedThreadId !== codexThreadId) {
2309
2677
  updateSessionById(runSessionId, { codexThreadId: returnedThreadId });
2310
2678
  const StorageDb = Modules.StorageDb;
2311
- if (StorageDb) {
2312
- const record = await StorageDb.getRecord('sessions', runSessionId);
2679
+ // The live session is saved below with its latest draft. Writing an
2680
+ // older DB snapshot here can give stale text a newer timestamp.
2681
+ if (StorageDb && !findSessionById(runSessionId)) {
2682
+ const record = await StorageDb.getRecord('sessions', runSessionId, { adopt: false });
2313
2683
  if (record) {
2314
2684
  record.codexThreadId = returnedThreadId;
2315
2685
  record.updatedAt = new Date().toISOString();
2316
- await StorageDb.putRecord('sessions', record);
2686
+ await StorageDb.putRecord('sessions', record, { adopt: false });
2317
2687
  }
2318
2688
  }
2319
2689
  }
@@ -2334,17 +2704,43 @@
2334
2704
  }
2335
2705
  } catch (error) {
2336
2706
  if (runCancellationRequested || isRunCancellationError(error)) {
2337
- appendRunCancelledReport();
2707
+ if (currentRunView?.reviewingUnavailablePreflight) {
2708
+ appendCompletionReport({ conclusionKey: 'launch_not_started',
2709
+ conclusion: tx('This task did not start: Codex did not run and no files were written.', '这轮任务没有开始:Codex 没有运行,也没有写入文件。'),
2710
+ status: 'blocked', operations: [], applyResults: [],
2711
+ failure: buildContentFailure('reviewing_unavailable', {}, {
2712
+ changedDocument: false, evidence: { declinedChoice: true }
2713
+ }),
2714
+ nextStep: tr('failureReason_reviewing_unavailable_cancelled_next') });
2715
+ } else appendRunCancelledReport();
2338
2716
  await finishRunView(tx('Cancelled', '已取消'), 'cancelled');
2339
2717
  void finalizeAuditRecord(runAuditDraft, { resultStatus: 'cancelled' });
2340
2718
  return;
2341
2719
  }
2720
+ if (compact) {
2721
+ const key = error.code === 'codex_compact_unsupported' ? 'compactUnsupported'
2722
+ : ['project_locked', 'codex_compact_thread_busy'].includes(error.code) ? 'compactBusy' : 'compactFailed';
2723
+ const contextLimit = FailureReasons.isContextLimitError(error.message, error.code)
2724
+ ? translateRawError(error.message, { locale: getLocale(), code: error.code }) : null;
2725
+ const failure = contextLimit ? buildContentFailure('codex_context_limit', { path: 'codex.run' }, {
2726
+ technicalMessage: error.message, evidence: { errorCode: error.code || '' }
2727
+ }) : undefined;
2728
+ const conclusion = contextLimit?.conclusion || tr(key);
2729
+ appendRunEvent({ title: conclusion, status: 'failed', failure,
2730
+ technicalDetail: { code: error.code || '', message: error.message } });
2731
+ appendCompletionReport({ conclusion, conclusionKey: contextLimit?.conclusionKey || (key === 'compactUnsupported'
2732
+ ? 'compact_unsupported' : key === 'compactBusy' ? 'compact_busy' : 'compact_failed'),
2733
+ nextStep: contextLimit?.nextStep, failure,
2734
+ status: 'failed', mode: 'ask', operations: [], applyResults: [] });
2735
+ await finishRunView(conclusion, 'failed');
2736
+ return;
2737
+ }
2342
2738
  // codexReturned: assistantMessage arrived on the stream before the
2343
2739
  // exception escaped, so Codex's answer is preserved in the conversation
2344
2740
  // — the fallback conclusion must reflect that instead of saying
2345
2741
  // 'local Codex returned no usable result'.
2346
2742
  const codexReturned = Boolean(getAssistantAnswerForCurrentRun());
2347
- const translated = translateRawError(error.message, { mode: submittedMode, locale: getLocale(), codexReturned });
2743
+ const translated = translateRawError(error.message, { mode: submittedMode, locale: getLocale(), code: error.code, codexReturned });
2348
2744
  // When translateRawError maps the raw error to a FailureReason code,
2349
2745
  // attach the structured failure to the run-event and completion-report
2350
2746
  // so the run record carries machine-readable failure data alongside
@@ -2372,7 +2768,7 @@
2372
2768
  }
2373
2769
  });
2374
2770
  appendCompletionReport({
2375
- conclusion: translated.conclusion,
2771
+ conclusion: translated.conclusion, conclusionKey: translated.conclusionKey,
2376
2772
  status: 'failed',
2377
2773
  operations: [],
2378
2774
  applyResults: [],
@@ -2386,6 +2782,10 @@
2386
2782
  blockedFiles: [{ path: 'task', reason: error.message }]
2387
2783
  });
2388
2784
  await finishRunView(tx('Task failed', '任务失败'), 'failed');
2785
+ // A failed initial card render must still leave a visible terminal record.
2786
+ if (currentRunView?.sessionId === state.activeSessionId && !getCurrentRunViewForRender()) {
2787
+ renderRunHistory({ preserveScroll: true });
2788
+ }
2389
2789
  } finally {
2390
2790
  const settledView = currentRunView;
2391
2791
  const settledRecord = settledView ? findRunRecord(settledView.recordId, settledView.sessionId) : null;
@@ -2422,6 +2822,8 @@
2422
2822
  submittedMode,
2423
2823
  submittedCustomInstructions,
2424
2824
  submittedSkillLoadingSettings,
2825
+ submittedEnabledLocalSkillIds,
2826
+ submittedEnabledSkillIds,
2425
2827
  submittedAttachments,
2426
2828
  submittedSkillInvocation,
2427
2829
  executionSnapshot
@@ -2430,8 +2832,7 @@
2430
2832
  title: tx('Starting the Codex skill installer.', '正在启动 Codex skill installer。'),
2431
2833
  status: 'running'
2432
2834
  });
2433
- const activeSession = findSessionById(runSessionId) || getActiveSession(state);
2434
- const codexThreadId = activeSession?.codexThreadId || '';
2835
+ const codexThreadId = currentRunView?.codexThreadId || '';
2435
2836
  const params = {
2436
2837
  projectId: getCurrentProjectId(),
2437
2838
  mode: submittedMode,
@@ -2439,21 +2840,24 @@
2439
2840
  model: executionSnapshot.model,
2440
2841
  reasoningEffort: executionSnapshot.reasoningEffort,
2441
2842
  speedTier: executionSnapshot.speedTier,
2442
- session: state.session,
2843
+ session: currentRunView?.sessionContext,
2844
+ locale: getLocale(),
2845
+ clientRunId: currentRunView?.recordId || '',
2846
+ clientSessionId: runSessionId,
2847
+ accountScopeId: currentRunView?.runAccountScopeId || '',
2443
2848
  threadId: codexThreadId || undefined,
2444
2849
  customInstructions: submittedCustomInstructions || undefined,
2445
2850
  loadCodexLocalSkills: submittedSkillLoadingSettings?.loadCodexLocalSkills !== false,
2446
2851
  loadCodexOverleafSkills: submittedSkillLoadingSettings?.loadCodexOverleafSkills !== false,
2852
+ enabledCodexLocalSkillIds: submittedEnabledLocalSkillIds,
2853
+ enabledCodexOverleafSkillIds: submittedEnabledSkillIds,
2447
2854
  attachments: submittedAttachments.length ? submittedAttachments : undefined,
2448
2855
  skillInvocation: submittedSkillInvocation,
2449
2856
  focusFiles: [],
2450
2857
  skipMirrorSync: true
2451
2858
  };
2452
2859
 
2453
- const response = await sendNative({
2454
- method: 'codex.run',
2455
- params
2456
- });
2860
+ const response = await sendTrackedCodexRun(params);
2457
2861
 
2458
2862
  if (!response.ok) {
2459
2863
  if (runCancellationRequested || isRunCancellationError(response.error)) {
@@ -2479,7 +2883,7 @@
2479
2883
  failure: translatedFailure
2480
2884
  });
2481
2885
  appendCompletionReport({
2482
- conclusion: translated.conclusion,
2886
+ conclusion: translated.conclusion, conclusionKey: translated.conclusionKey,
2483
2887
  status: 'failed',
2484
2888
  operations: [],
2485
2889
  applyResults: [],
@@ -2497,7 +2901,7 @@
2497
2901
  }
2498
2902
 
2499
2903
  const assistantMessage = response.result?.assistantMessage || getAssistantAnswerForCurrentRun();
2500
- appendCompletionReport({
2904
+ appendCompletionReport({ conclusionKey: !assistantMessage ? 'skill_installer_completed' : undefined,
2501
2905
  conclusion: assistantMessage || tx('Skill installer completed.', 'Skill installer 已完成。'),
2502
2906
  status: 'completed',
2503
2907
  operations: [],
@@ -2531,11 +2935,14 @@
2531
2935
  if (returnedThreadId && returnedThreadId !== codexThreadId) {
2532
2936
  updateSessionById(runSessionId, { codexThreadId: returnedThreadId });
2533
2937
  const StorageDb = Modules.StorageDb;
2534
- if (StorageDb) {
2535
- const record = await StorageDb.getRecord('sessions', runSessionId);
2938
+ // The installer already finished; a later envelope conflict only delays this save.
2939
+ if (findSessionById(runSessionId)) {
2940
+ saveStateSoon();
2941
+ } else if (StorageDb) {
2942
+ const record = await StorageDb.getRecord('sessions', runSessionId, { adopt: false });
2536
2943
  if (record) {
2537
2944
  record.codexThreadId = returnedThreadId;
2538
- await StorageDb.putRecord('sessions', record);
2945
+ await StorageDb.putRecord('sessions', record, { adopt: false });
2539
2946
  }
2540
2947
  }
2541
2948
  }
@@ -2549,8 +2956,6 @@
2549
2956
  if (mode === 'ask') {
2550
2957
  return { ok: true, skipped: true };
2551
2958
  }
2552
- const method = requireReviewing ? 'ensureReviewing' : 'ensureEditing';
2553
-
2554
2959
  appendRunEvent({
2555
2960
  title: requireReviewing
2556
2961
  ? tx('Checking Overleaf Reviewing/Track Changes before starting.', '正在确认 Overleaf 留痕状态。')
@@ -2558,30 +2963,67 @@
2558
2963
  status: 'running'
2559
2964
  });
2560
2965
 
2561
- let result = null;
2562
- try {
2563
- // Thread runProjectId through so the page-bridge dispatcher applies the
2564
- // writeGuard. Without it, a mid-flight SPA navigation could leave the
2565
- // pre-flight ensureReviewing/Editing toggle landing in the wrong
2566
- // project — Track Changes flipped on/off in a project the user did
2567
- // not intend to write to.
2568
- result = await callPageBridge(method, {
2569
- waitMs: 1800,
2570
- runProjectId: currentRunView?.runProjectId || ''
2571
- });
2572
- } catch (error) {
2573
- result = {
2574
- ok: false,
2575
- reason: error?.message || (requireReviewing
2576
- ? tx('Overleaf did not return track changes status', 'Overleaf 没有返回留痕状态')
2577
- : tx('Overleaf did not return Editing mode status', 'Overleaf 没有返回 Editing 模式状态')),
2578
- reviewing: null
2579
- };
2580
- }
2966
+ const owner = currentRunView;
2967
+ const signal = runCancellationController?.signal;
2968
+ const isCurrent = () => owner === currentRunView && !signal?.aborted && !runCancellationRequested
2969
+ && owner?.runProjectId === getCurrentProjectId() && owner?.runAccountScopeId === cachedAccountScopeId;
2970
+ const assertCurrent = () => { if (!isCurrent()) throw createRunCancellationError(); };
2971
+ assertCurrent();
2972
+ if (requireReviewing) {
2973
+ // Another tab may have observed this project's capability since hydration.
2974
+ const prefs = await Modules.StorageMigration.loadPrefs(owner.runAccountScopeId, owner.runProjectId);
2975
+ assertCurrent();
2976
+ const incoming = prefs?.reviewingAvailabilityByProject?.[owner.runProjectId];
2977
+ const current = projectSettingsCoordinator.getReviewingAvailability();
2978
+ if (incoming && (!current || incoming.observedAt > current.observedAt
2979
+ || (incoming.observedAt === current.observedAt && incoming.status === 'available'))) {
2980
+ state = { ...state, reviewingAvailabilityByProject: {
2981
+ ...state.reviewingAvailabilityByProject, [owner.runProjectId]: incoming
2982
+ } };
2983
+ syncProbeStatus();
2984
+ }
2985
+ }
2986
+ let result = await Modules.Reviewing.resolveWritePolicy({ requireReviewing,
2987
+ knownUnavailable: isReviewingKnownUnavailable() }, {
2988
+ assertCurrent,
2989
+ ensureMode: async required => {
2990
+ try {
2991
+ return await callPageBridge(required ? 'ensureReviewing' : 'ensureEditing', {
2992
+ waitMs: 1800, runProjectId: owner.runProjectId
2993
+ });
2994
+ } catch (error) {
2995
+ assertCurrent();
2996
+ return { ok: false, code: error?.code, reason: error?.message, reviewing: null };
2997
+ }
2998
+ },
2999
+ observe: async (status, evidence) => {
3000
+ projectSettingsCoordinator.observeReviewingAvailability(status, evidence);
3001
+ syncProbeStatus();
3002
+ // Publish a detected paywall before waiting for the user's decision.
3003
+ if (status === 'unavailable') await flushQueuedSaveState({ requireDurable: true });
3004
+ assertCurrent();
3005
+ },
3006
+ choose: () => {
3007
+ owner.reviewingUnavailablePreflight = true;
3008
+ return showPluginConfirm({
3009
+ title: tr('reviewingUnavailableTitle'),
3010
+ message: tr('reviewingUnavailableChoiceMessage'),
3011
+ choices: [
3012
+ { value: 'continue', label: tr('reviewingUnavailableContinue') },
3013
+ { value: 'disable', label: tr('reviewingUnavailableDisable'), secondary: true },
3014
+ { value: 'cancel', label: tr('confirmDefaultCancel') }
3015
+ ],
3016
+ signal, isCurrent, timeoutMs: REVIEWING_CONFIRM_TIMEOUT_MS
3017
+ });
3018
+ },
3019
+ applyUntracked: choice => { assertCurrent(); return options.onUntracked(choice); }
3020
+ });
3021
+ if (result.ok) owner.reviewingUnavailablePreflight = false;
3022
+ const effectiveRequireReviewing = result.requireReviewing ?? requireReviewing;
2581
3023
 
2582
3024
  if (result?.ok) {
2583
3025
  appendRunEvent({
2584
- title: requireReviewing
3026
+ title: effectiveRequireReviewing
2585
3027
  ? (result.activated
2586
3028
  ? tx('Overleaf Reviewing/Track Changes is now on. Starting the task.', '已开启 Overleaf 留痕,开始处理任务。')
2587
3029
  : tx('Overleaf Reviewing/Track Changes is already on. Starting the task.', 'Overleaf 留痕已经开启,开始处理任务。'))
@@ -2593,10 +3035,23 @@
2593
3035
  return result;
2594
3036
  }
2595
3037
 
2596
- const reason = localizeVisibleReason(result?.reason || (requireReviewing
3038
+ const unavailable = result?.code === 'reviewing_unavailable';
3039
+ if (unavailable && !result.failure) result.failure = buildContentFailure('reviewing_unavailable', {}, {
3040
+ changedDocument: false, evidence: result.evidence
3041
+ });
3042
+ const reason = unavailable ? tr('failureReason_reviewing_unavailable_user') : localizeVisibleReason(result?.reason || (effectiveRequireReviewing
2597
3043
  ? tx('Overleaf did not return track changes status', 'Overleaf 没有返回留痕状态')
2598
3044
  : tx('Overleaf did not return Editing mode status', 'Overleaf 没有返回 Editing 模式状态')));
2599
- const nextStep = requireReviewing
3045
+ // Affirmative choices retain trackingDecision even if the Editing check fails.
3046
+ const declinedReviewingChoice = owner.reviewingUnavailablePreflight === true && !result.trackingDecision;
3047
+ const reportFailure = unavailable && declinedReviewingChoice
3048
+ ? buildContentFailure('reviewing_unavailable', {}, {
3049
+ ...result.failure, changedDocument: false,
3050
+ evidence: { ...result.failure?.evidence, declinedChoice: true }
3051
+ }) : result?.failure;
3052
+ const nextStep = unavailable ? tr(declinedReviewingChoice
3053
+ ? 'failureReason_reviewing_unavailable_cancelled_next'
3054
+ : 'failureReason_reviewing_unavailable_next') : effectiveRequireReviewing
2600
3055
  ? tx(
2601
3056
  'You may not have permission, or this Overleaf page did not expose the Reviewing switch. Switch to Reviewing manually and retry, or turn off Track before writing.',
2602
3057
  '你可能没有权限,或 Overleaf 当前页面没有暴露切换入口。可以手动切到 Reviewing 后重试,或关闭“留痕”再运行。'
@@ -2606,7 +3061,7 @@
2606
3061
  '你可能没有权限,或 Overleaf 当前页面没有暴露切换入口。请在 Overleaf 手动切到 Editing 后重试。'
2607
3062
  );
2608
3063
  appendRunEvent({
2609
- title: requireReviewing
3064
+ title: effectiveRequireReviewing
2610
3065
  ? tx('Task not started: could not enable Overleaf Reviewing/Track Changes.', '任务未开始:无法开启 Overleaf 留痕。')
2611
3066
  : tx('Task not started: could not switch to Overleaf Editing.', '任务未开始:无法切换到 Overleaf Editing。'),
2612
3067
  status: 'failed',
@@ -2615,24 +3070,25 @@
2615
3070
  [tr('detailNext')]: nextStep
2616
3071
  },
2617
3072
  technicalDetail: {
2618
- reason,
3073
+ reason, code: result?.code, evidence: result?.evidence, failure: result?.failure,
2619
3074
  reviewing: result?.reviewing || null
2620
3075
  }
2621
3076
  });
2622
- appendCompletionReport({
3077
+ appendCompletionReport({ conclusionKey: 'launch_not_started',
2623
3078
  conclusion: tx('This task did not start: Codex did not run and no files were written.', '这轮任务没有开始:Codex 没有运行,也没有写入文件。'),
2624
3079
  status: 'blocked',
2625
3080
  operations: [],
2626
3081
  applyResults: [],
2627
- nextStep
3082
+ nextStep,
3083
+ reason: result?.code, failure: reportFailure
2628
3084
  });
2629
- const finishTitle = requireReviewing
3085
+ const finishTitle = effectiveRequireReviewing
2630
3086
  ? tx('Not started: could not enable Track Changes', '未开始:无法开启留痕')
2631
3087
  : tx('Not started: could not switch to Editing', '未开始:无法切换到 Editing');
2632
3088
  await finishRunView(finishTitle, 'failed');
2633
3089
  return {
2634
- ok: false,
2635
- reason,
3090
+ ...result, ok: false,
3091
+ reason: result?.code || reason,
2636
3092
  reviewing: result?.reviewing || null
2637
3093
  };
2638
3094
  }
@@ -2645,27 +3101,47 @@
2645
3101
  return runController.truncateSessionHistoryText(value, maxLength);
2646
3102
  }
2647
3103
 
2648
- function safeRunTask() {
3104
+ function safeRunTask(options = {}) {
3105
+ if (sessionManager.isTransitioning()) return;
2649
3106
  if (updateIdleClient?.isApplying()) {
2650
3107
  showPluginToast(tx('An update is being applied. Codex will be available again after reload.', '正在应用更新,重新加载后即可继续使用 Codex。'), { status: 'info' });
2651
3108
  return;
2652
3109
  }
3110
+ const submittedCompactCommand = options.operation !== 'compact'
3111
+ && panel?.querySelector('[data-task]')?.value.trim() === '/compact';
3112
+ const compact = options.operation === 'compact' || submittedCompactCommand;
3113
+ if (compact && (currentRunView || runSubmissionPending)) {
3114
+ showPluginToast(tr('compactBusy'), { status: 'info' });
3115
+ return;
3116
+ }
2653
3117
  if (currentRunView) {
2654
3118
  return queueComposerInput();
2655
3119
  }
2656
- runTask().catch(error => {
2657
- setRunning(false);
2658
- nativeChannel.clearActiveRequest();
2659
- stopRunElapsedTick();
2660
- currentRunView = null;
2661
- runCancellationController = null;
3120
+ if (runSubmissionPending) return;
3121
+ runSubmissionPending = true;
3122
+ runTask(compact ? { operation: 'compact', consumeCompactDraft: options.consumeCompactDraft === true || submittedCompactCommand } : {}).catch(error => {
3123
+ if (!currentRunView) {
3124
+ setRunning(false);
3125
+ nativeChannel.clearActiveRequest();
3126
+ stopRunElapsedTick();
3127
+ runCancellationController = null;
3128
+ }
2662
3129
  console.error('[codex-overleaf] failed to start task', error);
2663
- appendPlainLog(tx(`Could not start Codex task: ${error.message}`, `无法启动 Codex 任务:${error.message}`));
2664
- });
3130
+ appendPlainLog(tx(`Could not start Codex task: ${localizeVisibleReason(error.message)}`, `无法启动 Codex 任务:${localizeVisibleReason(error.message)}`));
3131
+ }).finally(() => { runSubmissionPending = false; });
2665
3132
  }
2666
3133
 
2667
3134
  const runGuidanceController = Modules.RunGuidanceController.create({
2668
- findRunRecord, appendRunEvent, getCurrentRunView: () => currentRunView,
3135
+ findRunRecord, getCurrentRunView: () => getCurrentRunViewForRender(),
3136
+ getActiveSessionId: () => state?.activeSessionId,
3137
+ getScope: () => ({ accountScopeId: cachedAccountScopeId, projectId: getCurrentProjectId(), generation: spaRouteGeneration }),
3138
+ getSessionCreatedAt: sessionId => findSessionById(sessionId)?.createdAt,
3139
+ getRunRoot: target => target.sessionId === state?.activeSessionId
3140
+ ? panel?.querySelector(`[data-run-id="${cssEscape(target.recordId)}"]`) : null,
3141
+ onRecordChanged: target => updateSessionById(target.sessionId, {}),
3142
+ onPresentationError: error => console.warn('[codex-overleaf] guidance presentation failed', error),
3143
+ RunGuidanceView: Modules.RunGuidanceView, tx, maxRunEvents: MAX_RUN_EVENTS,
3144
+ revealGuidance: row => runTimelineView.revealGuidance(row),
2669
3145
  renderRunEvent, cssEscape, bumpUnreadIfDetached
2670
3146
  });
2671
3147
 
@@ -2675,17 +3151,27 @@
2675
3151
  getActiveSession: () => getActiveSession(state),
2676
3152
  setQueue: (sessionId, pendingInputs) => updateSessionById(sessionId, { pendingInputs }),
2677
3153
  getCurrentRun: () => currentRunView,
2678
- canStart: sessionId => state?.activeSessionId === sessionId && isProjectEditorRoute(window.location),
3154
+ canStart: canStartQueuedSession,
2679
3155
  readInputs: readPanelInputs,
2680
3156
  getTask: () => panel?.querySelector('[data-task]')?.value || '',
2681
3157
  hasAttachments: () => getComposerAttachmentsForRun().length > 0,
2682
- capturePayload: () => RunExecutionSnapshot.toQueuePayload(captureCurrentExecutionSnapshot()),
3158
+ capturePayload: () => ({
3159
+ ...RunExecutionSnapshot.toQueuePayload(captureCurrentExecutionSnapshot()),
3160
+ submissionContext: RunInputQueue.normalizeSubmissionContext({
3161
+ customInstructions: getCustomInstructionsForCurrentProject(),
3162
+ skillLoadingSettings: getSkillLoadingSettings(),
3163
+ skillInvocation: getComposerSkillInvocationForRun(),
3164
+ enabledCodexOverleafSkillIds: getEnabledCodexOverleafSkillIds(),
3165
+ enabledCodexLocalSkillIds: getEnabledCodexLocalSkillIds()
3166
+ })
3167
+ }),
2683
3168
  prepareClaim: () => providerSettingsCoordinator.ensureLoaded(),
2684
3169
  resolveExecutionSnapshot: snapshot => RunExecutionSnapshot.resolveForExecution(snapshot, {
2685
3170
  catalog: providerSettingsCoordinator.getCatalog()
2686
3171
  }),
2687
3172
  clearTask: () => {
2688
3173
  panel.querySelector('[data-task]').value = '';
3174
+ composerDraftEditedHere = false;
2689
3175
  selectionContextView.clear();
2690
3176
  updateSessionById(state.activeSessionId, { task: '' });
2691
3177
  autosizeTaskTextarea();
@@ -2696,12 +3182,22 @@
2696
3182
  RunExecutionSnapshot.fromQueuePayload(item.payload),
2697
3183
  { catalog: providerSettingsCoordinator.getCatalog() }
2698
3184
  );
3185
+ // Promotion uses its captured snapshot; this line still describes the current draft.
3186
+ syncProbeStatus();
2699
3187
  },
2700
3188
  run: item => runTask({ queuedInput: item }),
2701
- sendSteer: params => sendBackgroundNative({ method: 'codex.steer', params }),
3189
+ getGuideScope: () => ({ projectId: getCurrentProjectId(), accountScopeId: cachedAccountScopeId, generation: spaRouteGeneration }),
3190
+ isGuideCancelled: () => runCancellationRequested || runCancellationController?.signal?.aborted === true,
3191
+ preflightGuide: ({ task, assertCurrent }) => runSensitivePreflight({
3192
+ task, project: { files: [] }, rules: getGovernanceRulesForCurrentProject(),
3193
+ guidance: true, useExistingMirror: false, assertCurrent
3194
+ }),
3195
+ sendSteer: (params, context) => nativeCompatibilityController.sendBackgroundNative({ method: 'codex.steer', params }, context),
2702
3196
  getContainer: () => panel?.querySelector('[data-pending-inputs]'),
2703
3197
  createView: options => PendingInputView.create(options),
2704
3198
  appendEvent: (title, status) => appendRunEvent({ title, status }),
3199
+ previewGuidance: runGuidanceController.preview,
3200
+ clearGuidancePreview: runGuidanceController.clearPreview,
2705
3201
  appendGuidance: runGuidanceController.upsert,
2706
3202
  removeGuidance: runGuidanceController.remove,
2707
3203
  toast: (message, status) => showPluginToast(message, { status }),
@@ -2740,7 +3236,29 @@
2740
3236
  });
2741
3237
  }
2742
3238
 
3239
+ function canStartQueuedSession(sessionId) {
3240
+ if (state?.activeSessionId !== sessionId || !isProjectEditorRoute(window.location)) return false;
3241
+ if (sessionManager.isTransitioning()) {
3242
+ deferredSessionStart = { sessionId, scope: getComposerDraftScope() };
3243
+ return false;
3244
+ }
3245
+ return true;
3246
+ }
3247
+
2743
3248
  function queueComposerInput() {
3249
+ if (sessionManager.isTransitioning()) return;
3250
+ if (getSkillLoadingSettings().loadCodexLocalSkills !== false && !Array.isArray(getCodexLocalSkillsCatalog())) {
3251
+ primeCodexLocalSkillsCatalog({ required: true }).catch(() => {});
3252
+ showPluginToast(tx('Local Codex skills are still loading. Try submitting again when loading finishes.',
3253
+ 'Codex 本地技能仍在加载,请在加载完成后重新提交。'), { status: 'info' });
3254
+ return;
3255
+ }
3256
+ const invocation = getComposerSkillInvocationForRun();
3257
+ if (invocation?.scope === 'codex-local' && !getEnabledCodexLocalSkillIds()?.includes(invocation.id)) {
3258
+ showPluginToast(tx('The selected local Codex skill is unavailable or disabled. Select an enabled skill and try again.',
3259
+ '所选 Codex 本地技能不可用或已关闭,请选择已启用的技能后重试。'), { status: 'failed' });
3260
+ return;
3261
+ }
2744
3262
  return runInputCoordinator?.queueComposerInput();
2745
3263
  }
2746
3264
 
@@ -2790,7 +3308,7 @@
2790
3308
  const nativeResult = results[0];
2791
3309
  const nativeResponse = nativeResult?.status === 'fulfilled' ? nativeResult.value : null;
2792
3310
  if (nativeResponse && !nativeResponse.ok) {
2793
- const message = nativeResponse?.error?.message || tx('native host did not respond', 'native host 没有响应');
3311
+ const message = localizeVisibleReason(nativeResponse?.error?.message || tx('native host did not respond', 'native host 没有响应'));
2794
3312
  appendRunEvent({
2795
3313
  title: tx(`Cancel request was not delivered: ${message}`, `中断请求没有送达:${message}`),
2796
3314
  status: 'failed'
@@ -2832,25 +3350,8 @@
2832
3350
  force: true
2833
3351
  }
2834
3352
  });
2835
- if (response?.ok) {
2836
- const result = response.result || response;
2837
- const released = result.lockReleased === true || result.cancelled === true;
2838
- appendPlainLog(released
2839
- ? tx(
2840
- `Force-released the stuck Codex run for project ${projectKey}. You can retry now.`,
2841
- `已强制释放项目 ${projectKey} 的 Codex 占用,可以重试了。`
2842
- )
2843
- : tx(
2844
- `No stuck run was found for project ${projectKey}.`,
2845
- `没有发现项目 ${projectKey} 上的卡住任务。`
2846
- ));
2847
- return response;
2848
- }
2849
- const message = response?.error?.message || tx('native host did not respond', 'native host 没有响应');
2850
- appendPlainLog(tx(
2851
- `Force-release request was not delivered: ${message}`,
2852
- `强制释放请求没有送达:${message}`
2853
- ));
3353
+ appendPlainLog(Modules.RunRecoveryActions.formatForceCancelOutcome(response, tx));
3354
+ if (response?.ok !== true && response?.error?.message) appendPlainLog(localizeVisibleReason(response.error.message));
2854
3355
  return response;
2855
3356
  }
2856
3357
 
@@ -2954,6 +3455,12 @@
2954
3455
  .filter(id => id && isCodexOverleafSkillEnabled(id));
2955
3456
  }
2956
3457
 
3458
+ function getEnabledCodexLocalSkillIds() {
3459
+ if (getSkillLoadingSettings().loadCodexLocalSkills === false) return [];
3460
+ const skills = getCodexLocalSkillsCatalog();
3461
+ return Array.isArray(skills) ? skills.filter(skill => isCodexLocalSkillEnabled(skill.id, skill)).map(skill => skill.id) : undefined;
3462
+ }
3463
+
2957
3464
  function buildCodexRunParams({
2958
3465
  task,
2959
3466
  project,
@@ -2966,15 +3473,20 @@
2966
3473
  compileLogContext,
2967
3474
  customInstructions,
2968
3475
  skillLoadingSettings,
3476
+ enabledCodexOverleafSkillIds,
3477
+ enabledCodexLocalSkillIds,
2969
3478
  attachments,
2970
3479
  skillInvocation,
2971
3480
  submittedMode
2972
3481
  } = {}) {
2973
3482
  const executionSnapshot = currentRunView?.executionSnapshot || captureCurrentExecutionSnapshot();
2974
- const submittedState = RunExecutionSnapshot.applyToState(state, executionSnapshot);
3483
+ const submittedState = RunExecutionSnapshot.applyToState({
3484
+ ...state, session: currentRunView?.sessionContext || state.session
3485
+ }, executionSnapshot);
2975
3486
  const params = runController.buildCodexRunParams({
2976
3487
  currentProjectId: getCurrentProjectId(),
2977
3488
  state: submittedState,
3489
+ locale: getLocale(),
2978
3490
  task,
2979
3491
  project,
2980
3492
  useExistingMirror,
@@ -2991,7 +3503,9 @@
2991
3503
  ? getSkillLoadingSettings()
2992
3504
  : { loadCodexLocalSkills: true, loadCodexOverleafSkills: true })
2993
3505
  : skillLoadingSettings,
2994
- enabledCodexOverleafSkillIds: getEnabledCodexOverleafSkillIds(),
3506
+ enabledCodexOverleafSkillIds: enabledCodexOverleafSkillIds === undefined
3507
+ ? getEnabledCodexOverleafSkillIds() : enabledCodexOverleafSkillIds,
3508
+ enabledCodexLocalSkillIds,
2995
3509
  attachments,
2996
3510
  skillInvocation,
2997
3511
  compileLogContext,
@@ -3009,11 +3523,26 @@
3009
3523
  }
3010
3524
 
3011
3525
  async function sendTrackedCodexRun(params) {
3012
- const compatibilityGate = await nativeCompatibilityController.ensureForMethod('codex.run');
3526
+ const owner = currentRunView;
3527
+ const signal = runCancellationController?.signal;
3528
+ const assertCurrent = () => {
3529
+ if (signal?.aborted || (owner && owner !== currentRunView)) throw createRunCancellationError();
3530
+ throwIfRunCancellationRequested();
3531
+ if (owner && (owner.runProjectId !== getCurrentProjectId()
3532
+ || owner.runAccountScopeId !== cachedAccountScopeId)) {
3533
+ throw Object.assign(createRunCancellationError(), {
3534
+ message: 'The active project or account changed before dispatch.'
3535
+ });
3536
+ }
3537
+ };
3538
+ const compatibilityGate = await nativeCompatibilityController.ensureForMethod('codex.run', { assertCurrent });
3539
+ assertCurrent();
3013
3540
  if (!compatibilityGate.ok) {
3014
3541
  return compatibilityGate.response;
3015
3542
  }
3016
- throwIfRunCancellationRequested();
3543
+ if (params.operation === 'compact' && compatibilityGate.compatibility?.native?.capabilities?.threadCompact !== true) {
3544
+ return { ok: false, error: { code: 'codex_compact_unsupported', message: 'This Native host does not support context compaction.' } };
3545
+ }
3017
3546
  const tracked = nativeChannel.sendNativeTracked(nativeCompatibilityController.attachEvidence({
3018
3547
  method: 'codex.run',
3019
3548
  params
@@ -3041,57 +3570,44 @@
3041
3570
  }
3042
3571
 
3043
3572
  async function recoverInterruptedRunJournals() {
3044
- const projectKey = getCurrentProjectId();
3573
+ const projectKey = getCurrentProjectId(), accountScopeId = cachedAccountScopeId;
3574
+ const generation = spaRouteGeneration;
3575
+ const isCurrent = () => generation === spaRouteGeneration
3576
+ && projectKey === getCurrentProjectId() && accountScopeId === cachedAccountScopeId;
3045
3577
  const recovery = await activeTurnControl.recoverJournals({
3046
3578
  projectKey,
3579
+ accountScopeId,
3580
+ isCurrent,
3581
+ onRecheck: async () => {
3582
+ if (!isCurrent()) return;
3583
+ await recoverInterruptedRunJournals();
3584
+ if (isCurrent()) applyStateToPanel();
3585
+ },
3047
3586
  requireOwnerLost: Modules.StorageDb.sharedSessionsEnabled?.() === true,
3048
3587
  normalizeInterruptedRun: record => Object.assign(record, Modules.SessionState.normalizeRuns([record], {
3049
3588
  restoreRunningRuns: true,
3050
3589
  locale: getLocale()
3051
3590
  })[0]),
3052
3591
  findSession: findSessionById,
3592
+ listSessions: () => state?.sessions || [],
3593
+ isLiveRun: (record, session) => currentRunView?.recordId === record.id
3594
+ && currentRunView.sessionId === session.id && currentRunView.runProjectId === projectKey,
3053
3595
  getActiveSession: () => getActiveSession(state),
3054
- createInterruptedRun: journal => ({
3055
- id: journal.clientRunId,
3056
- task: journal.task || 'interrupted task',
3057
- mode: journal.mode || 'ask',
3058
- model: journal.model || '',
3059
- status: 'interrupted',
3060
- statusText: tr('restoredRunStoppedStatus'),
3061
- runProjectId: projectKey,
3062
- startedAt: journal.createdAt || new Date().toISOString(),
3063
- finishedAt: journal.updatedAt || new Date().toISOString(),
3064
- events: [],
3065
- attachments: [],
3066
- undoOperations: [],
3067
- undoTrackedChanges: [],
3068
- undoExpectedFiles: []
3596
+ createInterruptedRun: journal => ActiveTurnControl.createInterruptedRun(journal, projectKey, tr('restoredRunStoppedStatus')),
3597
+ onRawEvent: (raw, { record, session, sequence }) => storeContextUsage(raw, record, session, sequence),
3598
+ mapEvent: (raw, journal) => ActiveTurnControl.mapRecoveredEvent(raw, journal, {
3599
+ mapActivity: value => mapAgentEventToActivity(value, { locale: getLocale() }),
3600
+ sanitizeText: sanitizeAssistantVisibleText, sanitizeValue: sanitizeAssistantVisibleValue
3069
3601
  }),
3070
- mapEvent: (raw, journal) => {
3071
- const activity = mapAgentEventToActivity(raw, { locale: getLocale() });
3072
- if (!activity?.visible || activity.kind === 'technical') {
3073
- return null;
3074
- }
3075
- return {
3076
- kind: activity.kind || 'activity',
3077
- title: sanitizeAssistantVisibleText(activity.title) || 'Event',
3078
- status: activity.status || 'running',
3079
- detail: sanitizeAssistantVisibleValue(activity.detail),
3080
- timestamp: raw.timestamp || journal.updatedAt || new Date().toISOString(),
3081
- streamKey: sanitizeAssistantVisibleText(activity.streamKey),
3082
- streamRole: sanitizeAssistantVisibleText(activity.streamRole),
3083
- appendText: sanitizeAssistantVisibleText(activity.appendText),
3084
- replaceText: activity.replaceText
3085
- };
3086
- },
3087
3602
  upsertStream: upsertRunStreamRecordEvent,
3088
3603
  pauseSessionQueue: (session, reason) => {
3089
3604
  session.pendingInputs = RunInputQueue.pauseAll(session.pendingInputs || [], reason);
3090
3605
  },
3091
3606
  maxEvents: MAX_RUN_EVENTS
3092
3607
  });
3608
+ if (!isCurrent()) return;
3093
3609
  if (recovery.changed) {
3094
- await saveState();
3610
+ if (await saveState({ recoveryRuns: recovery.recoveredRuns }) !== true || !isCurrent()) return;
3095
3611
  for (const requestId of recovery.acknowledgeIds) {
3096
3612
  await activeTurnControl.acknowledge(requestId).catch(() => false);
3097
3613
  }
@@ -3103,14 +3619,21 @@
3103
3619
  if (!StorageDb || !AuditRecords) {
3104
3620
  return null;
3105
3621
  }
3622
+ // Audit follows the captured run owner even if the visible page changes.
3623
+ // Missing owner identity must never be filled with a later account.
3624
+ const accountScopeId = currentRunView ? currentRunView.runAccountScopeId : cachedAccountScopeId;
3625
+ const projectId = currentRunView ? currentRunView.runProjectId : getCurrentProjectId();
3626
+ if (!accountScopeId || !projectId) return null;
3106
3627
  const executionSnapshot = input.executionSnapshot
3107
3628
  || currentRunView?.executionSnapshot
3108
3629
  || captureCurrentExecutionSnapshot();
3109
3630
  const draft = AuditRecords.buildAuditDraftRecord({
3110
- projectId: getCurrentProjectId(),
3631
+ accountScopeId,
3632
+ projectId,
3111
3633
  sessionId: input.sessionId || '',
3112
3634
  turnId: currentRunView?.recordId || '',
3113
3635
  mode: input.mode || executionSnapshot.mode,
3636
+ requireReviewing: executionSnapshot.requireReviewing,
3114
3637
  ...providerSettingsCoordinator.getProviderSnapshot(executionSnapshot.providerId),
3115
3638
  providerRevision: executionSnapshot.providerRevision,
3116
3639
  model: executionSnapshot.model,
@@ -3142,8 +3665,10 @@
3142
3665
  return draft;
3143
3666
  }
3144
3667
  const record = StorageDb.buildAuditLogRecord
3145
- ? StorageDb.buildAuditLogRecord({ ...draft, ...updates, id: draft.id, completedAt: updates.completedAt || draft.completedAt || '' })
3146
- : { ...draft, ...updates };
3668
+ ? StorageDb.buildAuditLogRecord({ ...draft, ...updates, id: draft.id,
3669
+ accountScopeId: draft.accountScopeId, projectId: draft.projectId,
3670
+ completedAt: updates.completedAt || draft.completedAt || '' })
3671
+ : { ...draft, ...updates, id: draft.id, accountScopeId: draft.accountScopeId, projectId: draft.projectId };
3147
3672
  return StorageDb.putRecord('auditLogs', record).then(() => record).catch(() => draft);
3148
3673
  }
3149
3674
 
@@ -3173,121 +3698,10 @@
3173
3698
  }
3174
3699
 
3175
3700
  async function runSensitivePreflight(options) {
3176
- options = options || {};
3177
- const { task, project, rules, useExistingMirror = false } = options;
3178
- const normalizedRules = normalizeGovernanceRules(rules);
3179
- if (normalizedRules.sensitiveCheckEnabled === false) {
3180
- return { blocked: false, findings: [] };
3181
- }
3182
- if (!SensitiveScan?.scanSensitiveInputs && !useExistingMirror) {
3183
- return { blocked: false, findings: [] };
3184
- }
3185
- let findings = SensitiveScan?.scanSensitiveInputs
3186
- ? SensitiveScan.scanSensitiveInputs({
3187
- task,
3188
- files: (project?.files || []).filter(isTextSnapshotFile)
3189
- })
3190
- : [];
3191
- if (useExistingMirror) {
3192
- findings = dedupeSensitiveFindings([
3193
- ...findings,
3194
- ...await scanNativeMirrorSensitiveFindings()
3195
- ]);
3196
- }
3197
- if (!findings.length) {
3198
- return { blocked: false, findings: [] };
3199
- }
3200
- appendRunEvent({
3201
- title: tx(
3202
- `Sensitive content check found ${findings.length} item(s).`,
3203
- `敏感内容检查发现 ${findings.length} 项。`
3204
- ),
3205
- status: 'failed',
3206
- detail: findings.slice(0, 8).map(finding => ({
3207
- [tr('detailFile')]: finding.path || finding.source || 'task',
3208
- [tr('detailReason')]: finding.detectorId,
3209
- [tx('Preview', '预览')]: finding.preview || '[REDACTED]'
3210
- }))
3211
- });
3212
- if (normalizedRules.sensitiveConfirmAllowed === true) {
3213
- const approved = await showPluginConfirm({
3214
- title: tr('sensitiveConfirmTitle'),
3215
- message: formatSensitiveConfirmMessage(findings),
3216
- confirmLabel: tr('sensitiveConfirmRun'),
3217
- cancelLabel: tr('confirmDefaultCancel'),
3218
- destructive: true
3219
- });
3220
- return { blocked: !approved, findings };
3221
- }
3222
- appendCompletionReport({
3223
- conclusion: tx(
3224
- 'This run was blocked before Codex received project context because sensitive content was detected.',
3225
- '检测到敏感内容,本轮已在向 Codex 发送项目上下文前阻止。'
3226
- ),
3227
- status: 'blocked',
3228
- operations: [],
3229
- applyResults: [],
3230
- nextStep: tx(
3231
- 'Remove the sensitive content, narrow @context, or enable explicit sensitive confirmation in Project Settings.',
3232
- '请移除敏感内容、缩小 @context,或在项目设置里允许敏感内容显式确认。'
3233
- )
3234
- });
3235
- return { blocked: true, findings };
3236
- }
3237
-
3238
- function formatSensitiveConfirmMessage(findings = []) {
3239
- const visible = findings.slice(0, 8).map(finding => {
3240
- const path = finding.path || finding.source || 'task';
3241
- return `${path}: ${finding.detectorId || 'sensitive'} (${finding.preview || '[REDACTED]'})`;
3242
- });
3243
- return [
3244
- tr('sensitiveConfirmMessage'),
3245
- '',
3246
- ...visible,
3247
- findings.length > visible.length ? tx(`${findings.length - visible.length} more finding(s).`, `另有 ${findings.length - visible.length} 项。`) : ''
3248
- ].filter(Boolean).join('\n');
3249
- }
3250
-
3251
- async function scanNativeMirrorSensitiveFindings() {
3252
- try {
3253
- const response = await sendBackgroundNative({
3254
- method: 'mirror.scanSensitive',
3255
- params: { projectId: getCurrentProjectId() }
3256
- });
3257
- if (response?.ok) {
3258
- return Array.isArray(response.result?.findings) ? response.result.findings : [];
3259
- }
3260
- return [{
3261
- detectorId: 'mirror-sensitive-scan-unavailable',
3262
- source: 'local-mirror',
3263
- preview: response?.error?.message || 'Could not scan the local mirror before Codex runs.'
3264
- }];
3265
- } catch (error) {
3266
- return [{
3267
- detectorId: 'mirror-sensitive-scan-unavailable',
3268
- source: 'local-mirror',
3269
- preview: error?.message || 'Could not scan the local mirror before Codex runs.'
3270
- }];
3271
- }
3272
- }
3273
-
3274
- function dedupeSensitiveFindings(findings = []) {
3275
- const seen = new Set();
3276
- const deduped = [];
3277
- for (const finding of findings || []) {
3278
- const key = [
3279
- finding?.detectorId || '',
3280
- finding?.source || '',
3281
- finding?.path || '',
3282
- finding?.preview || ''
3283
- ].join('\u0000');
3284
- if (seen.has(key)) {
3285
- continue;
3286
- }
3287
- seen.add(key);
3288
- deduped.push(finding);
3289
- }
3290
- return deduped;
3701
+ return Modules.SensitivePreflight.create({
3702
+ SensitiveScan, normalizeGovernanceRules, isTextSnapshotFile, sendBackgroundNative,
3703
+ getCurrentProjectId, appendRunEvent, appendCompletionReport, showPluginConfirm, tr, tx
3704
+ }).run(options);
3291
3705
  }
3292
3706
 
3293
3707
  function appendRunCancelledReport() {
@@ -3298,7 +3712,7 @@
3298
3712
  status: 'info',
3299
3713
  activity: { v: 1, kind: 'lifecycle', state: 'cancelled', target: 'cancelled' }
3300
3714
  });
3301
- appendCompletionReport({
3715
+ appendCompletionReport({ conclusionKey: 'cancelled',
3302
3716
  conclusion: tx('This run was cancelled. It did not continue syncing or writing to Overleaf.', '这轮已中断,没有继续同步或写入 Overleaf。'),
3303
3717
  status: 'cancelled',
3304
3718
  operations: [],
@@ -3327,7 +3741,8 @@
3327
3741
  const running = typeof runningOverride === 'boolean'
3328
3742
  ? runningOverride
3329
3743
  : Boolean(currentRunView);
3330
- const action = running && !hasInput ? 'cancel' : 'send';
3744
+ const transitioning = sessionManager.isTransitioning();
3745
+ const action = running && (!hasInput || transitioning) ? 'cancel' : 'send';
3331
3746
  const label = action === 'cancel'
3332
3747
  ? tr('cancelRun')
3333
3748
  : running
@@ -3336,13 +3751,17 @@
3336
3751
  runButton.dataset.action = action;
3337
3752
  runButton.disabled = action === 'cancel'
3338
3753
  ? runCancellationRequested
3339
- : !hasInput;
3754
+ : !hasInput || transitioning;
3340
3755
  runButton.title = label;
3341
3756
  runButton.setAttribute('aria-label', label);
3342
- if (currentRunView?.writebackRetry && hasInput) runButton.disabled = true;
3757
+ if (currentRunView?.writebackRetry && hasInput && action !== 'cancel') runButton.disabled = true;
3343
3758
  }
3344
3759
 
3345
3760
  function handleTaskInputKeydown(event) {
3761
+ if (sessionManager.isTransitioning()) {
3762
+ if (event.key === 'Enter' && !event.isComposing && event.keyCode !== 229) event.preventDefault();
3763
+ return;
3764
+ }
3346
3765
  if (handleSlashMenuKeydown(event)) {
3347
3766
  return;
3348
3767
  }
@@ -3354,6 +3773,7 @@
3354
3773
  }
3355
3774
 
3356
3775
  function handleTaskInput() {
3776
+ composerDraftRevision += 1;
3357
3777
  autosizeTaskTextarea();
3358
3778
  syncComposerSendAvailability();
3359
3779
  renderPendingInputs();
@@ -3369,17 +3789,18 @@
3369
3789
 
3370
3790
  function getSlashCommands() {
3371
3791
  const commands = [
3792
+ { id: 'compact', kind: 'compact', group: 'commands', title: tr('slashCompactTitle'), slug: '/compact',
3793
+ subtitle: tr('slashCompactSubtitle') },
3372
3794
  {
3373
3795
  id: 'install-skill',
3374
3796
  kind: 'installer',
3797
+ group: 'commands',
3375
3798
  title: tr('slashInstallSkillTitle'),
3376
3799
  subtitle: tr('slashInstallSkillSubtitle')
3377
3800
  }
3378
3801
  ];
3379
- if (getSkillLoadingSettings().loadCodexOverleafSkills === false) {
3380
- return commands;
3381
- }
3382
- for (const skill of slashCodexOverleafSkills) {
3802
+ const settings = getSkillLoadingSettings();
3803
+ for (const skill of settings.loadCodexOverleafSkills === false ? [] : slashCodexOverleafSkills) {
3383
3804
  const id = String(skill?.id || '').trim();
3384
3805
  if (!isSafeSkillId(id)) {
3385
3806
  continue;
@@ -3392,16 +3813,34 @@
3392
3813
  commands.push({
3393
3814
  id: `skill:${id}`,
3394
3815
  kind: 'codex-overleaf-skill',
3816
+ group: 'overleaf',
3395
3817
  scope: 'codex-overleaf',
3396
3818
  skillId: id,
3397
3819
  title,
3398
- subtitle: tr('slashUseSkillSubtitle')
3820
+ subtitle: tr('slashUseOverleafSkillSubtitle')
3399
3821
  });
3400
3822
  }
3823
+ const localSkills = settings.loadCodexLocalSkills === false ? [] : (getCodexLocalSkillsCatalog() || []);
3824
+ for (const skill of localSkills) {
3825
+ if (!isSafeSkillId(skill.id) || !isCodexLocalSkillEnabled(skill.id, skill)) continue;
3826
+ const sameName = localSkills.filter(other => String(other.title || other.name || other.id) === String(skill.title || skill.name || skill.id)).length > 1;
3827
+ // Source and duplicate hints lead so a truncated preview cannot hide them.
3828
+ commands.push({ id: `local-skill:${skill.id}`, kind: 'codex-local-skill', group: 'local', scope: 'codex-local', skillId: skill.id,
3829
+ title: String(skill.title || skill.name || skill.id).trim().slice(0, 80),
3830
+ searchName: String(skill.name || ''),
3831
+ subtitle: [skill.sourceKind === 'plugin' && skill.sourceLabel ? skill.sourceLabel : '',
3832
+ sameName ? skill.sourceHint || skill.id.slice(-8) : '',
3833
+ String(skill.preview || '').trim() || tr('slashUseLocalSkillSubtitle')].filter(Boolean).join(' · ') });
3834
+ }
3401
3835
  return commands;
3402
3836
  }
3403
3837
 
3404
3838
  async function refreshCodexOverleafSkillsForSlashMenu() {
3839
+ if (getSlashTrigger() && getSkillLoadingSettings().loadCodexLocalSkills !== false) {
3840
+ primeCodexLocalSkillsCatalog().catch(() => {
3841
+ showPluginToast(tx('Could not load local Codex skills. Try again.', '无法加载 Codex 本地技能,请重试。'), { status: 'failed' });
3842
+ });
3843
+ }
3405
3844
  if (slashCodexOverleafSkillsLoaded || slashCodexOverleafSkillsLoading) {
3406
3845
  return;
3407
3846
  }
@@ -3435,9 +3874,10 @@
3435
3874
  const commands = getSlashCommands().filter(command => {
3436
3875
  return !query
3437
3876
  || command.title.toLowerCase().includes(query)
3438
- || command.id.toLowerCase().includes(query);
3877
+ || command.id.toLowerCase().includes(query)
3878
+ || String(command.searchName || '').toLowerCase().includes(query);
3439
3879
  });
3440
- renderSlashMenu(commands);
3880
+ renderSlashMenu(commands, { query });
3441
3881
  return;
3442
3882
  }
3443
3883
  const atTrigger = getAtFileTrigger();
@@ -3535,7 +3975,7 @@
3535
3975
  closeSlashMenu();
3536
3976
  return;
3537
3977
  }
3538
- renderSlashMenu(entries);
3978
+ renderSlashMenu(entries, { query });
3539
3979
  }
3540
3980
 
3541
3981
  // Lazy one-shot load: the tray's file list is usually warm (panel open
@@ -3618,7 +4058,22 @@
3618
4058
  };
3619
4059
  }
3620
4060
 
3621
- function renderSlashMenu(commands = []) {
4061
+ const SLASH_MENU_GROUPS = { commands: 'slashGroupCommands', overleaf: 'slashGroupOverleafSkills', local: 'slashGroupLocalSkills' };
4062
+ const SLASH_MENU_ICONS = { compact: 'compact', installer: 'install', 'codex-overleaf-skill': 'book', 'codex-local-skill': 'terminal' };
4063
+
4064
+ function appendHighlightedText(node, value, query) {
4065
+ const text = String(value || '');
4066
+ const at = query ? text.toLowerCase().indexOf(query) : -1;
4067
+ if (at < 0) {
4068
+ node.append(text);
4069
+ return;
4070
+ }
4071
+ const mark = document.createElement('mark');
4072
+ mark.textContent = text.slice(at, at + query.length);
4073
+ node.append(text.slice(0, at), mark, text.slice(at + query.length));
4074
+ }
4075
+
4076
+ function renderSlashMenu(commands = [], { query = '' } = {}) {
3622
4077
  const menu = panel?.querySelector('[data-slash-menu]');
3623
4078
  if (!menu) {
3624
4079
  return;
@@ -3628,7 +4083,17 @@
3628
4083
  menu.hidden = true;
3629
4084
  return;
3630
4085
  }
4086
+ const usage = commands.some(command => command.kind === 'compact') ? getContextUsageDisplay().usage : null;
4087
+ const percent = usage?.contextWindow > 0 ? usage.usedTokens / usage.contextWindow * 100 : null;
4088
+ let group = '';
3631
4089
  commands.forEach((command, index) => {
4090
+ if (command.group && command.group !== group) {
4091
+ group = command.group;
4092
+ const label = document.createElement('div');
4093
+ label.className = 'codex-slash-menu-group';
4094
+ label.textContent = tr(SLASH_MENU_GROUPS[group]);
4095
+ menu.append(label);
4096
+ }
3632
4097
  const button = document.createElement('button');
3633
4098
  button.type = 'button';
3634
4099
  button.dataset.slashCommand = command.id;
@@ -3640,16 +4105,58 @@
3640
4105
  button.dataset.slashSkillScope = command.scope;
3641
4106
  }
3642
4107
  button.dataset.active = index === 0 ? 'true' : 'false';
4108
+ const icon = document.createElement('span');
4109
+ icon.className = 'codex-slash-menu-icon';
4110
+ icon.innerHTML = Modules.PanelIcons.svg(SLASH_MENU_ICONS[command.kind] || 'file');
4111
+ const text = document.createElement('span');
4112
+ text.className = 'codex-slash-menu-text';
3643
4113
  const title = document.createElement('span');
3644
- title.textContent = command.title;
4114
+ title.className = 'codex-slash-menu-title';
4115
+ appendHighlightedText(title, command.title, query);
4116
+ if (command.slug) {
4117
+ const slug = document.createElement('code');
4118
+ appendHighlightedText(slug, command.slug, query);
4119
+ title.append(slug);
4120
+ }
3645
4121
  const subtitle = document.createElement('small');
3646
4122
  subtitle.textContent = command.subtitle;
3647
- button.append(title, subtitle);
4123
+ subtitle.title = command.subtitle;
4124
+ text.append(title, subtitle);
4125
+ button.append(icon, text);
4126
+ if (command.kind === 'compact' && percent !== null) {
4127
+ const meta = document.createElement('span');
4128
+ meta.className = 'codex-slash-menu-meta';
4129
+ meta.dataset.level = Modules.ContextUsage.level(percent);
4130
+ meta.title = tr('contextUsageTitle');
4131
+ meta.textContent = new Intl.NumberFormat(getLocale() === 'zh' ? 'zh-CN' : 'en-US', { maximumFractionDigits: 0 }).format(percent) + '%';
4132
+ button.append(meta);
4133
+ }
3648
4134
  menu.append(button);
3649
4135
  });
4136
+ const foot = document.createElement('div');
4137
+ foot.className = 'codex-slash-menu-foot';
4138
+ foot.dataset.slashMenuFoot = '';
4139
+ for (const [keys, label] of [[['↑', '↓'], 'slashKeyMove'], [['Enter'], 'slashKeyRun'], [['Esc'], 'slashKeyClose']]) {
4140
+ const hint = document.createElement('span');
4141
+ for (const key of keys) hint.append(Object.assign(document.createElement('kbd'), { textContent: key }));
4142
+ hint.append(tr(label));
4143
+ foot.append(hint);
4144
+ }
4145
+ menu.append(foot);
3650
4146
  renderedSlashCommands = new Map(commands.map(command => [command.id, command]));
3651
4147
  menu.dataset.activeIndex = '0';
3652
4148
  menu.hidden = false;
4149
+ menu.scrollTop = 0;
4150
+ updateSlashMenuLayout();
4151
+ }
4152
+
4153
+ function updateSlashMenuLayout() {
4154
+ const menu = panel?.querySelector('[data-slash-menu]');
4155
+ const composer = panel?.querySelector('[data-composer-form]');
4156
+ if (!menu || menu.hidden || !composer) return;
4157
+ const visibleTop = Math.max(0, root.visualViewport?.offsetTop || 0, panel.getBoundingClientRect().top);
4158
+ const available = Math.max(0, Math.floor(composer.getBoundingClientRect().top - visibleTop - 12));
4159
+ menu.style.setProperty('--codex-slash-menu-space', `${available}px`);
3653
4160
  }
3654
4161
 
3655
4162
  function handleSlashMenuClick(event) {
@@ -3708,9 +4215,28 @@
3708
4215
  buttons.forEach((button, buttonIndex) => {
3709
4216
  button.dataset.active = buttonIndex === index ? 'true' : 'false';
3710
4217
  });
4218
+ const active = buttons[index];
4219
+ if (!active || !menu.clientHeight) return;
4220
+ // Wrapping to the first item also reveals its group label.
4221
+ if (index === 0) {
4222
+ menu.scrollTop = 0;
4223
+ return;
4224
+ }
4225
+ const viewport = menu.getBoundingClientRect();
4226
+ const item = active.getBoundingClientRect();
4227
+ const top = viewport.top + menu.clientTop;
4228
+ // The sticky key-hint footer covers the bottom of the scroll area.
4229
+ const bottom = top + menu.clientHeight - (menu.querySelector('[data-slash-menu-foot]')?.offsetHeight || 0);
4230
+ // Scroll only this popup; scrollIntoView may move the chat or editor too.
4231
+ if (item.height > menu.clientHeight || item.top < top) {
4232
+ menu.scrollTop += item.top - top;
4233
+ } else if (item.bottom > bottom) {
4234
+ menu.scrollTop += item.bottom - bottom;
4235
+ }
3711
4236
  }
3712
4237
 
3713
4238
  function selectSlashCommand(commandId) {
4239
+ if (sessionManager.isTransitioning()) return;
3714
4240
  const command = renderedSlashCommands.get(commandId);
3715
4241
  if (!command) {
3716
4242
  closeSlashMenu();
@@ -3723,13 +4249,18 @@
3723
4249
  applyAtFileSelection(command);
3724
4250
  return;
3725
4251
  }
4252
+ if (command.kind === 'compact') {
4253
+ closeSlashMenu();
4254
+ safeRunTask({ operation: 'compact', consumeCompactDraft: true });
4255
+ return;
4256
+ }
3726
4257
  clearSlashTriggerFromTaskInput();
3727
4258
  closeSlashMenu();
3728
4259
  if (command.kind === 'installer') {
3729
4260
  activateSkillInstallerComposerContext();
3730
4261
  return;
3731
4262
  }
3732
- if (command.kind === 'codex-overleaf-skill') {
4263
+ if (command.kind === 'codex-overleaf-skill' || command.kind === 'codex-local-skill') {
3733
4264
  activateCodexOverleafSkillComposerContext(command);
3734
4265
  }
3735
4266
  }
@@ -3756,6 +4287,7 @@
3756
4287
  }
3757
4288
 
3758
4289
  function activateSkillInstallerComposerContext() {
4290
+ composerDraftRevision += 1;
3759
4291
  composerSkillInvocation = {
3760
4292
  id: 'skill-installer',
3761
4293
  title: tr('skillInstallerComposerLabel')
@@ -3766,6 +4298,7 @@
3766
4298
  }
3767
4299
 
3768
4300
  function activateCodexOverleafSkillComposerContext(command) {
4301
+ composerDraftRevision += 1;
3769
4302
  const id = String(command?.skillId || '').trim();
3770
4303
  if (!isSafeSkillId(id)) {
3771
4304
  return;
@@ -3773,7 +4306,7 @@
3773
4306
  composerSkillInvocation = {
3774
4307
  id,
3775
4308
  title: String(command?.title || id).trim().slice(0, 80) || id,
3776
- scope: 'codex-overleaf'
4309
+ scope: command.scope === 'codex-local' ? 'codex-local' : 'codex-overleaf'
3777
4310
  };
3778
4311
  renderComposerSkillInvocation();
3779
4312
  const input = panel?.querySelector('[data-task]');
@@ -3781,6 +4314,7 @@
3781
4314
  }
3782
4315
 
3783
4316
  function clearComposerSkillInvocation() {
4317
+ composerDraftRevision += 1;
3784
4318
  composerSkillInvocation = null;
3785
4319
  renderComposerSkillInvocation();
3786
4320
  panel?.querySelector('[data-task]')?.focus?.();
@@ -3791,7 +4325,7 @@
3791
4325
  if (!normalized) {
3792
4326
  return null;
3793
4327
  }
3794
- if (normalized.id === 'skill-installer') {
4328
+ if (normalized.id === 'skill-installer' && !normalized.scope) {
3795
4329
  return {
3796
4330
  id: 'skill-installer',
3797
4331
  title: tr('skillInstallerComposerLabel')
@@ -3806,13 +4340,9 @@
3806
4340
  return null;
3807
4341
  }
3808
4342
  const title = String(value?.title || id).trim().slice(0, 80) || id;
3809
- if (id === 'skill-installer') {
3810
- return { id, title };
3811
- }
3812
- if (value?.scope !== 'codex-overleaf') {
3813
- return null;
3814
- }
3815
- return { id, title, scope: 'codex-overleaf' };
4343
+ if (id === 'skill-installer' && (!value?.scope || value.scope === 'builtin')) return { id, title };
4344
+ if (!['codex-overleaf', 'codex-local'].includes(value?.scope)) return null;
4345
+ return { id, title, scope: value.scope };
3816
4346
  }
3817
4347
 
3818
4348
  function isSafeSkillId(id) {
@@ -3833,7 +4363,7 @@
3833
4363
  }
3834
4364
  const clear = context.querySelector('[data-composer-skill-clear]');
3835
4365
  if (clear) {
3836
- const clearLabel = active?.id === 'skill-installer'
4366
+ const clearLabel = active?.id === 'skill-installer' && !active.scope
3837
4367
  ? tr('skillInstallerComposerClear')
3838
4368
  : tr('composerSkillClear');
3839
4369
  clear.title = clearLabel;
@@ -3996,18 +4526,17 @@
3996
4526
  // it from cross-project queries (spec §5.2 storage rules).
3997
4527
  window.codexOverleafDeriveAccountScopeId = () => cachedAccountScopeId;
3998
4528
  var sharedSessionViewSync = Modules.SharedSessionViewSync.install({
3999
- Bridge: Modules.SharedSessionBridge,
4000
- StorageDb: Modules.StorageDb,
4001
- getScope: () => ({
4002
- accountScopeId: cachedAccountScopeId,
4003
- projectId: getCurrentProjectId(),
4004
- generation: spaRouteGeneration
4005
- }),
4006
- getState: () => state,
4007
- isBusy: () => Boolean(readLiveRunViewForSaveStateGuard() || document.querySelector('dialog[open]')),
4008
- apply: sessions => {
4009
- state = normalizePanelState({ ...state, sessions }, { restoreRunningRuns: false });
4529
+ Bridge: Modules.SharedSessionBridge, StorageDb: Modules.StorageDb,
4530
+ getScope: () => ({ accountScopeId: cachedAccountScopeId, projectId: getCurrentProjectId(), generation: spaRouteGeneration }),
4531
+ getState: () => state, setState: next => { state = next; },
4532
+ // An in-flight Accept/Undo keeps its view until it settles; refresh applies afterwards.
4533
+ isBusy: () => Boolean(pendingDraftSave || readLiveRunViewForSaveStateGuard() || trackedChangeInFlight.size
4534
+ || runReviewController?.hasActiveReview?.() || document.querySelector('dialog[open]')),
4535
+ hasLocalDraft: current => Boolean(current.task) && composerDraftEditedHere,
4536
+ normalize: normalizePanelState,
4537
+ render: () => {
4010
4538
  applyStateToPanel();
4539
+ composerDraftEditedHere = false;
4011
4540
  },
4012
4541
  notice: error => appendStorageNoticeOnce('shared-history-refresh', tx(
4013
4542
  `Could not refresh shared history: ${error.message}`,
@@ -4092,7 +4621,7 @@
4092
4621
  chromeApi: chrome, getState: () => state, setState: next => { state = next; }, getPanel: () => panel,
4093
4622
  applyTheme: applyPanelTheme, applyLocale: () => { applyLocaleToPanel(); refreshDashboardSoon(); },
4094
4623
  onPreloadChange: enabled => contextTrayController.scheduleContextPrefetch({ enabled }),
4095
- onSkillsChange: () => { renderLocalSkillList(); updateSkillsEntrySummary(); },
4624
+ onSkillsChange: () => { renderLocalSkillList(); updateSkillsEntrySummary(); updateSlashMenuForTaskInput(); },
4096
4625
  onError: error => showPluginToast(tx(
4097
4626
  `Global settings could not be saved or loaded: ${error.message}. Please retry.`,
4098
4627
  `全局设置未能保存或读取:${error.message}。请重试。`), { status: 'failed', sticky: true })
@@ -4108,6 +4637,8 @@
4108
4637
  storageKey = getProjectStorageKey(LEGACY_STORAGE_KEY, window.location.href);
4109
4638
  const reloaded = await loadStoredState();
4110
4639
  state = normalizeLoadedPanelState(reloaded);
4640
+ composerDraftEditedHere = false;
4641
+ await getRunReviewController().recoverInterruptedUndoStatuses();
4111
4642
  applyStateToPanel();
4112
4643
  } catch (_error) {
4113
4644
  // Reload failures fall back to the in-memory state; the next saveState
@@ -4169,6 +4700,8 @@
4169
4700
  function leaveActiveProject(newId) {
4170
4701
  const prevId = activeProjectId;
4171
4702
  activeProjectId = newId; // null for non-project URLs (spec §5.7.1)
4703
+ if (prevId !== newId) composerDraftEditedHere = false;
4704
+ getComposerDraftPersistence().syncScope();
4172
4705
  if (!prevId || prevId === newId) {
4173
4706
  return;
4174
4707
  }
@@ -4194,7 +4727,15 @@
4194
4727
  }
4195
4728
 
4196
4729
  async function onSpaRouteChange() {
4730
+ // Snapshot the departing draft before hydration replaces state. The detached
4731
+ // writer retains its original account/project and never reads the new view's attachments.
4732
+ if (pendingDraftSave?.projectId === activeProjectId
4733
+ && pendingDraftSave?.accountScopeId === cachedAccountScopeId) {
4734
+ void saveState({ draftScope: pendingDraftSave });
4735
+ }
4736
+ resetDraftSave();
4197
4737
  const generation = ++spaRouteGeneration;
4738
+ activeTurnControl.cancelRecovery();
4198
4739
  const url = new URL(window.location.href);
4199
4740
  const pathname = url.pathname;
4200
4741
  const previousProjectId = activeProjectId;
@@ -4209,6 +4750,7 @@
4209
4750
  // project derivation: account menu is global chrome and should be
4210
4751
  // readable on per-project URLs too).
4211
4752
  await refreshAccountScopeId();
4753
+ getComposerDraftPersistence().syncScope();
4212
4754
  if (generation !== spaRouteGeneration || pathname !== window.location.pathname) {
4213
4755
  return;
4214
4756
  }
@@ -4417,7 +4959,7 @@
4417
4959
  operations: [],
4418
4960
  status: tr('modeAsk')
4419
4961
  });
4420
- appendCompletionReport({
4962
+ appendCompletionReport({ conclusionKey: !notes ? 'ask_inspected' : undefined,
4421
4963
  conclusion: notes || tx('This run only inspected and explained; no files were written.', '这轮只做了检查和说明,没有写入文件。'),
4422
4964
  status: tr('modeAsk'),
4423
4965
  notes,
@@ -4437,7 +4979,7 @@
4437
4979
  const applied = await applyTaskOperations(project, operations);
4438
4980
  applyResults.push(applied);
4439
4981
  appendApplyResult(applied);
4440
- recordUndoFromApply(project, applied);
4982
+ await recordUndoFromApply(project, applied);
4441
4983
  const approved = await showPluginConfirm({
4442
4984
  title: tx('Confirm delete plan?', '确认删除计划?'),
4443
4985
  message: formatDeletePlan(result.deletePlan || []),
@@ -4451,7 +4993,7 @@
4451
4993
  const deleteApplied = await applyTaskOperations(project, pendingOperations, { allowHighRisk: true });
4452
4994
  applyResults.push(deleteApplied);
4453
4995
  appendApplyResult(deleteApplied);
4454
- recordUndoFromApply(project, deleteApplied);
4996
+ await recordUndoFromApply(project, deleteApplied);
4455
4997
  appendLog(tx('Deleted files after confirmation.', '已按确认删除文件。'));
4456
4998
  const summaryLine = appendChangeSummary({
4457
4999
  notes,
@@ -4459,7 +5001,7 @@
4459
5001
  applyResults,
4460
5002
  status: 'applied with delete plan'
4461
5003
  });
4462
- appendCompletionReport({
5004
+ appendCompletionReport({ conclusionKey: !notes ? 'delete_handled' : undefined,
4463
5005
  conclusion: notes || tx('Changes were written, and delete items were handled after your confirmation.', '已写入修改,并按你的确认处理删除项。'),
4464
5006
  status: 'applied with delete plan',
4465
5007
  notes,
@@ -4482,7 +5024,7 @@
4482
5024
  status: 'applied without deletes',
4483
5025
  deletePlanRejected: true
4484
5026
  });
4485
- appendCompletionReport({
5027
+ appendCompletionReport({ conclusionKey: 'delete_rejected',
4486
5028
  conclusion: tx('Non-delete changes were kept; delete items were not written.', '已保留非删除修改;删除项没有写入。'),
4487
5029
  status: 'applied without deletes',
4488
5030
  notes,
@@ -4504,7 +5046,7 @@
4504
5046
  appendOperationsPreview(operations, tx('Preparing to write', '准备写入'));
4505
5047
  const applied = await applyTaskOperations(project, operations);
4506
5048
  appendApplyResult(applied);
4507
- recordUndoFromApply(project, applied);
5049
+ await recordUndoFromApply(project, applied);
4508
5050
  appendLog(mode === 'auto' ? tx('Auto write task completed.', '自动写入任务完成。') : tx('Task completed.', '任务完成。'));
4509
5051
  const summaryLine = appendChangeSummary({
4510
5052
  notes,
@@ -4512,7 +5054,7 @@
4512
5054
  applyResults: [applied],
4513
5055
  status: result.status || 'completed'
4514
5056
  });
4515
- appendCompletionReport({
5057
+ appendCompletionReport({ conclusionKey: !notes ? 'run_complete' : undefined,
4516
5058
  conclusion: notes || tx('This run is complete.', '这轮任务已完成。'),
4517
5059
  status: result.status || 'completed',
4518
5060
  notes,
@@ -4529,6 +5071,7 @@
4529
5071
  }
4530
5072
 
4531
5073
  async function applyTaskOperations(project, operations, options = {}) {
5074
+ const target = currentRunView;
4532
5075
  const runRequireReviewing = typeof options.requireReviewing === 'boolean'
4533
5076
  ? options.requireReviewing
4534
5077
  : state.requireReviewing === true;
@@ -4538,7 +5081,13 @@
4538
5081
  title: tr('undoNoWritesTitle'),
4539
5082
  status: 'completed'
4540
5083
  });
4541
- } else {
5084
+ }
5085
+ const reviewing = await ensureReviewingBeforeWrite(partitioned.safe, { requireReviewing: runRequireReviewing });
5086
+ if (!reviewing.ok) {
5087
+ return buildReviewingBlockedApplyResult(partitioned.safe, reviewing, partitioned.skipped);
5088
+ }
5089
+ if (partitioned.safe.length) await stageWritebackRecovery(partitioned.safe, target, runRequireReviewing, project);
5090
+ if (partitioned.safe.length) {
4542
5091
  appendRunEvent({
4543
5092
  title: tx('Saved a recoverable version before this run writes.', '已保存本轮写入前的可恢复版本。'),
4544
5093
  status: 'completed',
@@ -4547,10 +5096,7 @@
4547
5096
  }
4548
5097
  });
4549
5098
  }
4550
- const reviewing = await ensureReviewingBeforeWrite(partitioned.safe, { requireReviewing: runRequireReviewing });
4551
- if (!reviewing.ok) {
4552
- return buildReviewingBlockedApplyResult(partitioned.safe, reviewing, partitioned.skipped);
4553
- }
5099
+
4554
5100
  const applied = partitioned.safe.length
4555
5101
  ? await callPageBridge('applyOperations', {
4556
5102
  operations: partitioned.safe,
@@ -4558,7 +5104,7 @@
4558
5104
  requireReviewing: runRequireReviewing,
4559
5105
  requireEditing: !runRequireReviewing,
4560
5106
  // Welcome-panel + write-guard:
4561
- runProjectId: currentRunView?.runProjectId || ''
5107
+ runProjectId: target?.runProjectId || ''
4562
5108
  })
4563
5109
  : { ok: true, applied: [], skipped: [] };
4564
5110
 
@@ -4590,10 +5136,19 @@
4590
5136
  // Thread runProjectId so the page-side dispatcher gates the toggle. Same
4591
5137
  // intent as the pre-flight check above: do not flip Track Changes /
4592
5138
  // Editing in a different project than the run is bound to.
5139
+ const owner = currentRunView;
4593
5140
  const result = await callPageBridge(method, {
4594
5141
  waitMs: 1800,
4595
5142
  runProjectId: currentRunView?.runProjectId || ''
4596
5143
  });
5144
+ throwIfRunCancellationRequested();
5145
+ if (owner && (owner !== currentRunView || owner.runProjectId !== getCurrentProjectId()
5146
+ || owner.runAccountScopeId !== cachedAccountScopeId)) throw createRunCancellationError();
5147
+ if (requireReviewing && (result?.ok || result?.code === 'reviewing_unavailable')) {
5148
+ projectSettingsCoordinator.observeReviewingAvailability(result.ok ? 'available' : 'unavailable',
5149
+ result.ok ? 'reviewing_confirmed' : 'upgrade_modal');
5150
+ syncProbeStatus();
5151
+ }
4597
5152
  if (result?.ok) {
4598
5153
  appendRunEvent({
4599
5154
  title: requireReviewing
@@ -4625,6 +5180,7 @@
4625
5180
  code: result?.code || (requireReviewing ? 'reviewing_not_enabled' : 'editing_not_confirmed'),
4626
5181
  reason: result?.reason || (requireReviewing ? 'Reviewing/Track Changes was not enabled' : 'Editing mode was not confirmed'),
4627
5182
  reviewing: result?.reviewing || null,
5183
+ evidence: result?.evidence, failure: result?.failure,
4628
5184
  requireReviewing
4629
5185
  };
4630
5186
  }
@@ -4639,8 +5195,9 @@
4639
5195
  operation,
4640
5196
  result: {
4641
5197
  ok: false,
4642
- code: blockedByEditing ? 'editing_not_confirmed' : 'reviewing_not_enabled',
4643
- reason: blockedByEditing
5198
+ code: reviewing?.code || (blockedByEditing ? 'editing_not_confirmed' : 'reviewing_not_enabled'),
5199
+ evidence: reviewing?.evidence, failure: reviewing?.failure,
5200
+ reason: reviewing?.code === 'reviewing_unavailable' ? tr('failureReason_reviewing_unavailable_user') : blockedByEditing
4644
5201
  ? tx(
4645
5202
  'Track is off, but Overleaf Editing mode was not verified before writing. Codex did not write this file.',
4646
5203
  '已关闭“留痕”,但写入前没有确认 Overleaf 正在用 Editing 模式。Codex 没有写入这个文件。'
@@ -4694,13 +5251,13 @@
4694
5251
 
4695
5252
  async function refreshProbe(options = {}) {
4696
5253
  const projectId = getCurrentProjectId();
5254
+ const accountScopeId = cachedAccountScopeId;
4697
5255
  const userInitiated = options.userInitiated === true;
4698
5256
  if (userInitiated) {
4699
5257
  setRefreshProbeLoading(true);
4700
5258
  const status = panel?.querySelector('[data-probe-status]');
4701
5259
  if (status) {
4702
- status.textContent = tr('refreshProbeLoading');
4703
- status.dataset.ok = 'false';
5260
+ renderProbeStatusMessage(status, tr('refreshProbeLoading'));
4704
5261
  status.dataset.refreshing = 'true';
4705
5262
  }
4706
5263
  }
@@ -4710,13 +5267,14 @@
4710
5267
  manualOverride: state?.requireReviewing === false
4711
5268
  });
4712
5269
  if (projectId !== getCurrentProjectId()) return null;
5270
+ if (accountScopeId !== cachedAccountScopeId) return null;
5271
+ if (probe.capabilities?.reviewing?.confirmed === true) {
5272
+ projectSettingsCoordinator.observeReviewingAvailability('available', 'reviewing_confirmed');
5273
+ }
4713
5274
  probeStatusSnapshot = { projectId, probe, userInitiated };
4714
5275
  const status = panel?.querySelector('[data-probe-status]');
4715
5276
  if (status) {
4716
- status.textContent = userInitiated
4717
- ? tr('refreshProbeDone', { status: formatProbeStatusBar(probe) })
4718
- : formatProbeStatusBar(probe);
4719
- status.dataset.ok = isProbeReadyForCurrentMode(probe) ? 'true' : 'false';
5277
+ renderProbeStatusLine(status, probe, { refreshed: userInitiated });
4720
5278
  status.dataset.refreshing = 'false';
4721
5279
  }
4722
5280
  if (!options.quiet && !userInitiated) {
@@ -4733,8 +5291,7 @@
4733
5291
  probeStatusSnapshot = { projectId, failed: true };
4734
5292
  const status = panel?.querySelector('[data-probe-status]');
4735
5293
  if (status) {
4736
- status.textContent = tr('refreshProbeFailed');
4737
- status.dataset.ok = 'false';
5294
+ renderProbeStatusMessage(status, tr('refreshProbeFailed'), 'warn');
4738
5295
  status.dataset.refreshing = 'false';
4739
5296
  }
4740
5297
  console.warn('[codex-overleaf] refresh probe failed', error);
@@ -4754,23 +5311,81 @@
4754
5311
  button.dataset.loading = loading ? 'true' : 'false';
4755
5312
  button.disabled = Boolean(loading);
4756
5313
  button.setAttribute('aria-busy', loading ? 'true' : 'false');
5314
+ setElementTitleAndAria('[data-refresh]', tr(loading ? 'refreshProbeLoading' : 'refreshProbe'),
5315
+ tr(loading ? 'refreshProbeLoading' : 'refreshProbeTitle'));
4757
5316
  }
4758
5317
 
4759
5318
  function syncProbeStatus(mode = state?.mode) {
4760
5319
  const status = panel?.querySelector('[data-probe-status]');
4761
5320
  if (!status) return;
4762
5321
  if (status.dataset.refreshing === 'true') {
4763
- status.textContent = tr('refreshProbeLoading');
5322
+ renderProbeStatusMessage(status, tr('refreshProbeLoading'));
4764
5323
  return;
4765
5324
  }
4766
5325
  if (probeStatusSnapshot?.projectId !== getCurrentProjectId()) return;
4767
5326
  const { failed, probe, userInitiated } = probeStatusSnapshot;
4768
- const text = failed ? tr('refreshProbeFailed') : formatProbeStatusBar(probe, mode);
4769
- status.textContent = userInitiated && !failed ? tr('refreshProbeDone', { status: text }) : text;
4770
- status.dataset.ok = !failed && isProbeReadyForCurrentMode(probe, mode) ? 'true' : 'false';
5327
+ if (failed) renderProbeStatusMessage(status, tr('refreshProbeFailed'), 'warn');
5328
+ else renderProbeStatusLine(status, probe, { refreshed: userInitiated });
4771
5329
  if (!failed) updateExistingProbeNotice(probe, mode);
4772
5330
  }
4773
5331
 
5332
+ // Keep the read scope visible; a known Track limitation adds its own warning segment.
5333
+ function getProbeScope(probe) {
5334
+ const selection = selectionContextView.capture();
5335
+ const focusFiles = getActiveFocusFiles();
5336
+ // Match submission priority: an edit selection, then explicit focus files,
5337
+ // then reference context, ahead of the editor's implicit current-file hint.
5338
+ if (selection?.mode === 'edit') return { icon: 'file', key: 'probeScopeSelection' };
5339
+ if (focusFiles.some(file => /\.tex$/i.test(file))) return { icon: 'file', key: 'probeScopeFiles' };
5340
+ if (focusFiles.length || selection) return { icon: 'attach', key: 'probeScopeContext' };
5341
+ if (getProbeRunReadiness(probe).editorOk) return { icon: 'file', key: 'probeScopeCurrentFile' };
5342
+ return { icon: 'folder', key: 'probeScopeProject' };
5343
+ }
5344
+
5345
+ function isReviewingKnownUnavailable() {
5346
+ return Modules.Reviewing.knownUnavailable(projectSettingsCoordinator.getReviewingAvailability());
5347
+ }
5348
+
5349
+ function syncReviewingTooltip() {
5350
+ const toggle = panel?.querySelector('[data-require-reviewing]');
5351
+ const title = tr(isReviewingKnownUnavailable() ? 'reviewingUnavailableTooltip' : 'requireReviewingTitle');
5352
+ if (toggle) toggle.title = title;
5353
+ const label = toggle?.closest('.codex-review-toggle');
5354
+ if (label) label.title = title;
5355
+ }
5356
+
5357
+ function renderProbeStatusLine(status, probe, options = {}) {
5358
+ const scope = getProbeScope(probe);
5359
+ const parts = [];
5360
+ if (options.refreshed) parts.push(tr('refreshProbeDone', { status: '' }).trim());
5361
+ const label = document.createElement('span');
5362
+ label.className = 'codex-probe-scope';
5363
+ label.innerHTML = Modules.PanelIcons?.svg?.(scope.icon) || ''; // Static, repository-owned icon path.
5364
+ label.append(tr(scope.key));
5365
+ parts.push(label);
5366
+ if (isExperimentalOtEnabled()) {
5367
+ const separator = document.createElement('span');
5368
+ separator.className = 'codex-probe-sep';
5369
+ separator.textContent = '·';
5370
+ parts.push(separator, 'OT ' + formatOtStatusLabel(getCurrentOtStatus()));
5371
+ }
5372
+ if (state.requireReviewing && isReviewingKnownUnavailable()) {
5373
+ const warning = document.createElement('span');
5374
+ warning.dataset.tone = 'warn';
5375
+ warning.textContent = ' · ' + tr('reviewingUnavailableStatus');
5376
+ parts.push(warning);
5377
+ }
5378
+ syncReviewingTooltip();
5379
+ status.replaceChildren(...parts);
5380
+ delete status.dataset.tone;
5381
+ }
5382
+
5383
+ function renderProbeStatusMessage(status, text, tone = '') {
5384
+ status.replaceChildren(text);
5385
+ if (tone) status.dataset.tone = tone;
5386
+ else delete status.dataset.tone;
5387
+ }
5388
+
4774
5389
  function formatProbeStatusBar(probe, mode = state?.mode) {
4775
5390
  const readiness = getProbeRunReadiness(probe);
4776
5391
  const reviewingOk = readiness.reviewingOk;
@@ -4804,17 +5419,7 @@
4804
5419
  if (!status || status.dataset.refreshing === 'true') {
4805
5420
  return;
4806
5421
  }
4807
- const text = String(status.textContent || '');
4808
- if (!text) {
4809
- return;
4810
- }
4811
- const base = text.split(' · OT ')[0];
4812
- status.textContent = appendOtStatusToProbeStatus(base);
4813
- }
4814
-
4815
- function isProbeReadyForCurrentMode(probe, mode = state?.mode) {
4816
- const readiness = getProbeRunReadiness(probe);
4817
- return mode === 'ask' || readiness.reviewingOk;
5422
+ syncProbeStatus();
4818
5423
  }
4819
5424
 
4820
5425
  function getProbeRunReadiness(probe) {
@@ -4919,8 +5524,8 @@
4919
5524
  ready: true,
4920
5525
  message: manualOverride
4921
5526
  ? tx(
4922
- `Ready: you confirmed Overleaf Track Changes is on. Codex ${readiness.contextLabel}.`,
4923
- `可以运行:你已确认 Overleaf 已开启留痕,Codex ${readiness.contextLabel}。`
5527
+ `Ready: Codex writes without tracked changes and ${readiness.contextLabel}.`,
5528
+ `可以运行:Codex 写入时不使用留痕,并且${readiness.contextLabel}。`
4924
5529
  )
4925
5530
  : tx(
4926
5531
  `Ready: Codex verified Overleaf Reviewing/Track Changes is on and ${readiness.contextLabel}.`,
@@ -4929,21 +5534,13 @@
4929
5534
  };
4930
5535
  }
4931
5536
 
4932
- if (readiness.contextReady) {
4933
- return {
4934
- ready: false,
4935
- message: tx(
4936
- `Not safe to write yet: Codex ${readiness.contextLabel}, but Overleaf Reviewing/Track Changes was not verified. Turn on Reviewing first, or disable Track below.`,
4937
- `还不能安全写入:Codex ${readiness.contextLabel},但没有确认 Overleaf 已开启 Reviewing/Track Changes。请先打开 Reviewing,或关闭下方的安全检查。`
4938
- )
4939
- };
4940
- }
4941
-
5537
+ // ensureReviewingBeforeWrite switches Overleaf to Reviewing before the first
5538
+ // write and blocks the write only if that cannot be confirmed.
4942
5539
  return {
4943
- ready: false,
5540
+ ready: true,
4944
5541
  message: tx(
4945
- '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.',
4946
- '还不能安全写入:请先在 Overleaf 打开 Reviewing/Track Changes;Codex 会在运行时读取整个项目,也可以用 @file 指定重点文件。'
5542
+ `Ready: Codex ${readiness.contextLabel} and will switch Overleaf to Reviewing/Track Changes before writing.`,
5543
+ `可以运行:Codex ${readiness.contextLabel},写入前会把 Overleaf 切到 Reviewing/Track Changes。`
4947
5544
  )
4948
5545
  };
4949
5546
  }
@@ -4962,7 +5559,7 @@
4962
5559
  focusFiles: getActiveFocusFiles()
4963
5560
  });
4964
5561
  const sanitizedProject = sanitizeRunProjectSnapshot(project);
4965
- if (sanitizedProject?.capabilities?.fullProjectSnapshot) {
5562
+ if (sanitizedProject?.capabilities?.fullProjectSnapshot || sanitizedProject?.capabilities?.pathIdentityComplete === false) {
4966
5563
  return sanitizedProject;
4967
5564
  }
4968
5565
  const pageStateProject = await callPageBridge('getProjectSnapshot', {
@@ -4983,25 +5580,37 @@
4983
5580
  }
4984
5581
 
4985
5582
  function sanitizeRunProjectSnapshot(project) {
4986
- if (!project || typeof project !== 'object') {
4987
- return project;
4988
- }
4989
- const activePath = normalizeSnapshotPath(project.activePath || '');
5583
+ if (!project || typeof project !== 'object') return project;
5584
+ const normalize = Modules.ProjectFiles.normalizeSafeProjectPath;
4990
5585
  const files = Array.isArray(project.files) ? project.files : [];
4991
- const filePathSet = new Set(files.map(file => normalizeSnapshotPath(file?.path || '')).filter(Boolean));
4992
- const activePathKnown = activePath && filePathSet.has(activePath);
4993
- if (!activePath || activePathKnown) {
4994
- return project;
5586
+ const counts = new Map();
5587
+ for (const file of files) {
5588
+ const path = normalize(file?.path);
5589
+ if (path) counts.set(path, (counts.get(path) || 0) + 1);
4995
5590
  }
5591
+ const skipped = [];
5592
+ const safeFiles = files.filter(file => {
5593
+ const path = normalize(file?.path);
5594
+ if (path && counts.get(path) === 1) return true;
5595
+ skipped.push({ path: typeof file?.path === 'string' ? file.path : '',
5596
+ reason: path ? 'snapshot_path_duplicate' : 'snapshot_path_invalid' });
5597
+ return false;
5598
+ });
5599
+ const activePath = normalize(project.activePath || '');
5600
+ const activePathKnown = activePath && safeFiles.some(file => file.path === activePath);
5601
+ const rejectActive = Boolean(project.activePath && !activePathKnown);
5602
+ if (!skipped.length && !rejectActive) return project;
4996
5603
  return {
4997
5604
  ...project,
4998
- activePath: '',
4999
- files: files.map(file => file?.active ? { ...file, active: false } : file),
5605
+ activePath: rejectActive ? '' : project.activePath,
5606
+ files: safeFiles.map(file => rejectActive && file?.active ? { ...file, active: false } : file),
5000
5607
  capabilities: {
5001
5608
  ...(project.capabilities || {}),
5609
+ ...(skipped.length ? { fullProjectSnapshot: false, contentComplete: false, pathIdentityComplete: false,
5610
+ skipped: [...(project.capabilities?.skipped || []), ...skipped] } : {}),
5002
5611
  diagnostics: {
5003
5612
  ...(project.capabilities?.diagnostics || {}),
5004
- rejectedActivePath: project.activePath
5613
+ ...(rejectActive ? { rejectedActivePath: project.activePath } : {})
5005
5614
  }
5006
5615
  }
5007
5616
  };
@@ -5087,11 +5696,8 @@
5087
5696
  }
5088
5697
 
5089
5698
  function normalizeSnapshotPath(path) {
5090
- return String(path || '')
5091
- .replace(/^@file:/i, '')
5092
- .replace(/\\/g, '/')
5093
- .trim()
5094
- .replace(/^\/+/, '');
5699
+ // Snapshot/overlay/focus arrays already carry canonical names from file chips.
5700
+ return Modules.ProjectFiles.normalizeSafeProjectPath(path);
5095
5701
  }
5096
5702
 
5097
5703
  function mergeProjectWithSyncChangeBaseFiles(project = {}, syncChanges = []) {
@@ -5303,8 +5909,10 @@
5303
5909
  legacyKey,
5304
5910
  accountScopeId
5305
5911
  );
5912
+ const composerAttachments = await loadComposerAttachmentsForProject(accountScopeId, projectId);
5306
5913
 
5307
5914
  return {
5915
+ composerAttachments,
5308
5916
  model: prefs.model || '', providerId: prefs.providerId || 'builtin',
5309
5917
  reasoningEffort: prefs.reasoningEffort || '',
5310
5918
  speedTier: prefs.speedTier || '',
@@ -5316,8 +5924,10 @@
5316
5924
  loadCodexLocalSkills: prefs.loadCodexLocalSkills !== false,
5317
5925
  loadCodexOverleafSkills: prefs.loadCodexOverleafSkills !== false,
5318
5926
  codexOverleafSkillEnabled: prefs.codexOverleafSkillEnabled || {},
5927
+ codexLocalSkillEnabled: prefs.codexLocalSkillEnabled || {},
5319
5928
  experimentalOtByProject: prefs.experimentalOtByProject || {},
5320
5929
  customInstructionsByProject: prefs.customInstructionsByProject || {},
5930
+ reviewingAvailabilityByProject: prefs.reviewingAvailabilityByProject || {},
5321
5931
  governanceRulesByProject: normalizeGovernanceRulesByProject(prefs.governanceRulesByProject),
5322
5932
  panelWidth: prefs.panelWidth || PANEL_DEFAULT_WIDTH,
5323
5933
  sessions: sessions.map(session => ({
@@ -5328,6 +5938,9 @@
5328
5938
  focusFiles: session.focusFiles || [], selectionContext: session.selectionContext?.projectId === projectId ? session.selectionContext : null,
5329
5939
  projectReferenceFiles: session.projectReferenceFiles || [],
5330
5940
  codexThreadId: session.codexThreadId || '',
5941
+ contextUsage: session.contextUsage || null,
5942
+ forkedFromSessionId: session.forkedFromSessionId || '',
5943
+ forkedFromRunId: session.forkedFromRunId || '',
5331
5944
  createdAt: session.createdAt,
5332
5945
  updatedAt: session.updatedAt,
5333
5946
  runs: Array.isArray(session.runs) ? session.runs : [],
@@ -5368,8 +5981,10 @@
5368
5981
  loadCodexLocalSkills: fallback.loadCodexLocalSkills !== false,
5369
5982
  loadCodexOverleafSkills: fallback.loadCodexOverleafSkills !== false,
5370
5983
  codexOverleafSkillEnabled: fallback.codexOverleafSkillEnabled || {},
5984
+ codexLocalSkillEnabled: fallback.codexLocalSkillEnabled || {},
5371
5985
  experimentalOtByProject: fallback.experimentalOtByProject || {},
5372
5986
  customInstructionsByProject: fallback.customInstructionsByProject || {},
5987
+ reviewingAvailabilityByProject: fallback.reviewingAvailabilityByProject || {},
5373
5988
  governanceRulesByProject: normalizeGovernanceRulesByProject(fallback.governanceRulesByProject),
5374
5989
  panelWidth: fallback.panelWidth || PANEL_DEFAULT_WIDTH,
5375
5990
  sessions: [],
@@ -5401,30 +6016,15 @@
5401
6016
  try {
5402
6017
  const StorageDb = Modules.StorageDb;
5403
6018
  const Migration = Modules.StorageMigration;
5404
- // Welcome-panel + write-guard:
5405
- // post-navigation persistence gate. The normal URL-derived projectId
5406
- // belongs to the route the user is *currently looking at*. When a run
5407
- // is in flight on a different project (mid-run SPA navigation), saving
5408
- // under that URL-derived id would pollute another project's Recent-
5409
- // projects entry and surface bogus disabled rows. Two safety lanes:
5410
- // 1. `options.projectIdOverride` lets a caller that legitimately
5411
- // needs to persist to a non-active project supply the projectId
5412
- // explicitly (the only such caller today is the post-navigation
5413
- // settlement path in `persistPostNavigationRunStatus`, which goes
5414
- // directly through StorageDb — but the override is in place so
5415
- // future callers do not have to monkey-patch around saveState).
5416
- // 2. When no override is set AND a navigation-divergent run is in
5417
- // flight (currentRunView.runProjectId !== URL-derived id), log a
5418
- // warning and skip persistence. The settlement path owns that
5419
- // run's persistence; routing through saveState would write to the
5420
- // WRONG projectId record.
5421
- const projectIdOverride = options && typeof options.projectIdOverride === 'string'
5422
- ? options.projectIdOverride
5423
- : '';
6019
+ // Settlement owns a run after navigation away from its project. Ordinary
6020
+ // view saves must not write that run into the current route's project.
6021
+ // An explicit override is reserved for callers that own a detached scope.
6022
+ const projectIdOverride = options?.draftScope?.projectId || (options && typeof options.projectIdOverride === 'string'
6023
+ ? options.projectIdOverride : '');
5424
6024
  const urlProjectId = getCurrentProjectId();
5425
6025
  const projectId = projectIdOverride || urlProjectId;
5426
6026
  if (!projectId) {
5427
- if (options?.reviewRunIds?.length) throw new Error('Review persistence lost its project.'); return;
6027
+ if (options?.requireDurable || options?.reviewRunIds?.length) throw new Error('Persistence lost its project.'); return;
5428
6028
  }
5429
6029
  // Read currentRunView through a defensive accessor so historical test
5430
6030
  // harnesses that inline `saveState` without re-declaring the module-
@@ -5436,28 +6036,41 @@
5436
6036
  'Skipped saveState: run is in flight on a different project than the current URL; settlement owns persistence.',
5437
6037
  '已跳过 saveState:本次运行所属项目与当前 URL 不一致,写回由 settlement 负责。'
5438
6038
  ));
5439
- if (options?.reviewRunIds?.length) throw new Error('Review persistence project changed.'); return;
6039
+ if (options?.requireDurable || options?.reviewRunIds?.length) throw new Error('Persistence project changed.'); return;
5440
6040
  }
6041
+ const savedDraft = !projectIdOverride && pendingDraftSave?.scope === getComposerDraftScope()
6042
+ ? pendingDraftSave : null;
5441
6043
  const compactState = prepareStateForStorage(state, { onAggressive: notifyAggressiveCompactionOnce });
5442
6044
  compactState.autoRecompile = state.autoRecompile;
5443
6045
  compactState.loadCodexLocalSkills = state.loadCodexLocalSkills !== false;
5444
6046
  compactState.loadCodexOverleafSkills = state.loadCodexOverleafSkills !== false;
5445
6047
  compactState.codexOverleafSkillEnabled = getCodexOverleafSkillEnabled();
6048
+ compactState.codexLocalSkillEnabled = getCodexLocalSkillEnabled();
5446
6049
  compactState.experimentalOtByProject = state.experimentalOtByProject;
5447
6050
  compactState.customInstructionsByProject = state.customInstructionsByProject;
6051
+ compactState.reviewingAvailabilityByProject = state.reviewingAvailabilityByProject;
5448
6052
  compactState.governanceRulesByProject = state.governanceRulesByProject;
5449
6053
  const stateSnapshot = typeof structuredClone === 'function'
5450
6054
  ? structuredClone(state)
5451
6055
  : JSON.parse(JSON.stringify(state));
6056
+ // Keep consumption provenance on the live run owner across ordinary
6057
+ // session normalization; it is not a composer edit or a queued input.
6058
+ const consumedSession = liveRunView?.draftConsumption && liveRunView.runProjectId === projectId
6059
+ && liveRunView.runAccountScopeId === cachedAccountScopeId
6060
+ ? stateSnapshot.sessions?.find(session => session.id === liveRunView.sessionId) : null;
6061
+ if (consumedSession?.task === '') consumedSession.draftConsumption = { ...liveRunView.draftConsumption };
5452
6062
  const compactStateSnapshot = typeof structuredClone === 'function'
5453
6063
  ? structuredClone(compactState)
5454
6064
  : JSON.parse(JSON.stringify(compactState));
5455
6065
 
5456
- const persistScopedState = persistenceContext => Modules.ScopedPersistenceCoordinator.persistPanelState({
6066
+ const retentionScope = { accountScopeId: typeof cachedAccountScopeId === 'string' ? cachedAccountScopeId : '', projectId, generation: typeof spaRouteGeneration === 'number' ? spaRouteGeneration : null };
6067
+ let written;
6068
+ const persistScopedState = async persistenceContext => {
6069
+ written = await Modules.ScopedPersistenceCoordinator.persistPanelState({
5457
6070
  state: stateSnapshot,
5458
6071
  compactState: compactStateSnapshot,
5459
6072
  projectId,
5460
- deletedSessionIds: options?.deletedSessionIds, reviewRunIds: options?.reviewRunIds,
6073
+ deletedSessionIds: options?.deletedSessionIds, reviewRunIds: options?.reviewRunIds, recoveryRuns: options?.recoveryRuns,
5461
6074
  Migration,
5462
6075
  StorageDb,
5463
6076
  SessionPersistence: Modules.SessionPersistence,
@@ -5465,7 +6078,11 @@
5465
6078
  normalizeGovernanceRulesByProject,
5466
6079
  normalizeCustomInstructionsByProject,
5467
6080
  persistenceContext
5468
- });
6081
+ });
6082
+ if (!options?.draftScope) {
6083
+ await getComposerDraftPersistence().save({ accountScopeId: persistenceContext.scope.accountScopeId, projectId });
6084
+ }
6085
+ };
5469
6086
 
5470
6087
  const persistenceCoordinator = getScopedPersistenceCoordinator();
5471
6088
  if (!persistenceCoordinator) {
@@ -5473,19 +6090,18 @@
5473
6090
  code: 'scoped_persistence_unavailable'
5474
6091
  });
5475
6092
  } else {
5476
- const committed = await persistenceCoordinator.commit(
5477
- projectId,
5478
- {
6093
+ const commitOptions = {
5479
6094
  detached: Boolean(projectIdOverride),
5480
6095
  rebase: true,
5481
6096
  queueMutation: options?.queueMutation,
5482
6097
  queueMutations: options?.queueMutations,
5483
6098
  sessionTombstones: options?.deletedSessionIds
5484
- },
5485
- persistScopedState
5486
- );
6099
+ };
6100
+ const committed = options?.draftScope
6101
+ ? await persistenceCoordinator.commitDetached(projectId, options.draftScope.accountScopeId, commitOptions, persistScopedState)
6102
+ : await persistenceCoordinator.commit(projectId, commitOptions, persistScopedState);
5487
6103
  if (!committed.ok && (
5488
- options?.reviewRunIds?.length || options?.queueMutation
6104
+ options?.requireDurable || options?.reviewRunIds?.length || options?.recoveryRuns?.length || options?.queueMutation
5489
6105
  || options?.queueMutations?.length
5490
6106
  || committed.reason !== 'stale_view'
5491
6107
  )) {
@@ -5493,8 +6109,35 @@
5493
6109
  code: committed.reason
5494
6110
  });
5495
6111
  }
6112
+ if (committed.ok) {
6113
+ if (savedDraft && pendingDraftSave === savedDraft) {
6114
+ const capturedTask = stateSnapshot.sessions.find(item => item.id === stateSnapshot.activeSessionId)?.task;
6115
+ const storedTask = StorageDb.buildSessionRecord({ task: capturedTask }).task;
6116
+ if (!(written || []).some(session => session.id === stateSnapshot.activeSessionId && session.task === storedTask)) {
6117
+ throw Object.assign(new Error(tx('The latest draft could not be saved. It is still available in this page.',
6118
+ '最新草稿未能保存,内容仍保留在当前页面。')), { code: 'draft_state_not_persisted' });
6119
+ }
6120
+ // A newer input revision keeps its own timer, even if this save finishes later.
6121
+ resetDraftSave();
6122
+ }
6123
+ const retentionUpdates = StorageDb.projectRetentionUpdates?.({ liveState: state, capturedState: stateSnapshot, written,
6124
+ capturedScope: retentionScope, currentScope: { accountScopeId: typeof cachedAccountScopeId === 'string' ? cachedAccountScopeId : '',
6125
+ projectId: getCurrentProjectId(), generation: typeof spaRouteGeneration === 'number' ? spaRouteGeneration : null } }) || [];
6126
+ for (const update of retentionUpdates) for (const run of [...(state.runs || []), ...(state.sessions || []).flatMap(session => session.runs || [])]) {
6127
+ if (run.id !== update.runId) continue;
6128
+ if (update.retention) run.recoveryRetention = update.retention; else delete run.recoveryRetention;
6129
+ }
6130
+ if (retentionUpdates.some(update => update.retention)) appendStorageNoticeOnce('undo-retention', tx(
6131
+ 'Some recovery evidence was not retained durably. After reloading, check Overleaf history or Review.',
6132
+ '部分恢复材料未持久保留;刷新后请核对 Overleaf 历史或审阅。'));
6133
+ }
6134
+ if (options?.requireDurable || options?.recoveryRuns?.length) return true;
5496
6135
  }
5497
6136
  } catch (error) {
6137
+ if (options?.requireDurable) throw error;
6138
+ // A departing draft must never fall back using the destination page's state/scope.
6139
+ if (options?.draftScope) return;
6140
+ if (error?.code === 'recovery_scope_changed') return;
5498
6141
  if (
5499
6142
  error?.code === 'account_scope_unavailable' &&
5500
6143
  !options?.reviewRunIds?.length && !options?.queueMutation &&
@@ -5522,7 +6165,7 @@
5522
6165
  emitStorageQuotaFailure(error, fallbackError);
5523
6166
  }
5524
6167
  if (typeof appendStorageNoticeOnce === 'function') {
5525
- appendStorageNoticeOnce('save-failed', tx(`Failed to save session state: ${error.message}`, `保存会话状态失败:${error.message}`));
6168
+ appendStorageNoticeOnce('save-failed', tx(`Failed to save session state: ${localizeVisibleReason(error.message)}`, `保存会话状态失败:${localizeVisibleReason(error.message)}`));
5526
6169
  } else {
5527
6170
  throw error;
5528
6171
  }
@@ -5531,12 +6174,12 @@
5531
6174
  }
5532
6175
  }
5533
6176
 
5534
-
5535
6177
  function prepareCompactFallbackState(inputState) {
5536
6178
  const compact = prepareStateForStorage(inputState);
5537
6179
  return {
5538
6180
  ...compact,
5539
6181
  __codexOverleafCompactFallback: true,
6182
+ composerAttachments: [],
5540
6183
  task: '',
5541
6184
  session: null,
5542
6185
  sessions: [],
@@ -5611,6 +6254,65 @@
5611
6254
  }, delayMs);
5612
6255
  }
5613
6256
 
6257
+ function getComposerDraftScope() {
6258
+ return JSON.stringify([cachedAccountScopeId, getCurrentProjectId(), spaRouteGeneration, state.activeSessionId]);
6259
+ }
6260
+
6261
+ function resetDraftSave() {
6262
+ if (draftSaveTimer) clearTimeout(draftSaveTimer);
6263
+ draftSaveTimer = null;
6264
+ pendingDraftSave = null;
6265
+ }
6266
+
6267
+ function captureComposerDraft() {
6268
+ const input = panel?.querySelector('[data-task]');
6269
+ if (!input || !state.activeSessionId) return;
6270
+ const next = updateActiveSessionDraft(state, input.value);
6271
+ if (next === state) return;
6272
+ if (next.task !== state.task) {
6273
+ composerDraftEditedHere = true;
6274
+ if (currentRunView?.sessionId === state.activeSessionId) delete currentRunView.draftConsumption;
6275
+ }
6276
+ state = next;
6277
+ const now = Date.now(), scope = getComposerDraftScope();
6278
+ pendingDraftSave = {
6279
+ scope,
6280
+ projectId: getCurrentProjectId(),
6281
+ accountScopeId: cachedAccountScopeId,
6282
+ firstAt: pendingDraftSave?.scope === scope ? pendingDraftSave.firstAt : now
6283
+ };
6284
+ if (draftSaveTimer) clearTimeout(draftSaveTimer);
6285
+ const delay = Math.max(0, Math.min(DRAFT_SAVE_DELAY_MS, pendingDraftSave.firstAt + DRAFT_SAVE_MAX_WAIT_MS - now));
6286
+ draftSaveTimer = setTimeout(() => {
6287
+ draftSaveTimer = null;
6288
+ if (pendingDraftSave?.scope !== getComposerDraftScope()) { resetDraftSave(); return; }
6289
+ pendingDraftSave.firstAt = Date.now();
6290
+ // Join the existing writer immediately; never reset its timer on every key.
6291
+ saveStateSoon(0);
6292
+ }, delay);
6293
+ }
6294
+
6295
+ async function flushComposerDraft({ requireDurable = false } = {}) {
6296
+ if (!pendingDraftSave) return;
6297
+ const scope = getComposerDraftScope();
6298
+ if (pendingDraftSave.scope !== scope) { resetDraftSave(); return; }
6299
+ if (draftSaveTimer) clearTimeout(draftSaveTimer);
6300
+ draftSaveTimer = null;
6301
+ pendingDraftSave.firstAt = Date.now();
6302
+ await flushQueuedSaveState(requireDurable ? { requireDurable: true } : null);
6303
+ if (requireDurable && scope !== getComposerDraftScope()) {
6304
+ throw new Error(tx('The active session changed while saving the draft. Try again.',
6305
+ '保存草稿时活动会话已改变,请重试。'));
6306
+ }
6307
+ }
6308
+
6309
+ function flushComposerDraftOnLeave() {
6310
+ // Lifecycle events cannot await completion; periodic saves remain the primary protection.
6311
+ flushComposerDraft().catch(error => appendStorageNoticeOnce('save-failed', tx(
6312
+ `Failed to save session state: ${localizeVisibleReason(error.message)}`,
6313
+ `保存会话状态失败:${localizeVisibleReason(error.message)}`)));
6314
+ }
6315
+
5614
6316
  function runQueuedSaveState() {
5615
6317
  if (saveStateInFlightPromise) {
5616
6318
  saveStateRunAfterFlight = true;
@@ -5629,7 +6331,7 @@
5629
6331
  // here (e.g. fallback chrome.storage.local.set rethrew).
5630
6332
  emitStorageQuotaFailure(error, null);
5631
6333
  }
5632
- if (persistenceOptions?.reviewRunIds?.length) throw error;
6334
+ if (persistenceOptions?.requireDurable || persistenceOptions?.reviewRunIds?.length) throw error;
5633
6335
  appendPlainLog(tx(`Failed to save session state: ${formatStateSaveError(error)}`, `保存会话状态失败:${formatStateSaveError(error)}`));
5634
6336
  })
5635
6337
  .finally(() => {
@@ -5705,19 +6407,17 @@
5705
6407
  if (isStorageQuotaError(error)) {
5706
6408
  return tx('Local session history is too large. Delete some old tasks and retry.', '本地会话记录太大,请删除一些旧任务后重试。');
5707
6409
  }
5708
- return error?.message || String(error);
6410
+ return localizeVisibleReason(error?.message || String(error));
5709
6411
  }
5710
6412
 
5711
6413
  async function persistPanelInputs(event) {
5712
6414
  const globalWrite = getGlobalPreferences().handleInput(event);
5713
6415
  if (globalWrite) return globalWrite;
5714
- // Typing in the task box is the hottest write path: a full saveState per
5715
- // keystroke serializes every session/run/event and rewrites IndexedDB, so
5716
- // panels get slower as history grows. Drafts take the debounced saver;
5717
- // committed control changes keep the immediate full save below.
5718
- if (event?.type === 'input' && event?.target?.matches?.('[data-task]')) {
5719
- readPanelInputs();
5720
- saveStateSoon();
6416
+ // Draft edits retain run/event identity. Composition updates also capture the
6417
+ // current text; compositionend captures its committed value if no final input fires.
6418
+ if (event?.target?.matches?.('[data-task]')) {
6419
+ captureComposerDraft();
6420
+ if (event.type === 'change') await flushComposerDraft();
5721
6421
  return;
5722
6422
  }
5723
6423
  if (event?.target?.matches?.('[data-speed]')) {
@@ -5729,8 +6429,18 @@
5729
6429
  }
5730
6430
  renderSpeedOptions(getRenderedModelEntries());
5731
6431
  readPanelInputs();
6432
+ // Only a user re-enabling Track requests another capability check. The
6433
+ // preceding input event and programmatic session renders must not reset it.
6434
+ if (event?.type === 'change' && event.isTrusted === true
6435
+ && event.target?.matches?.('[data-require-reviewing]') && event.target.checked
6436
+ && isReviewingKnownUnavailable()) {
6437
+ projectSettingsCoordinator.observeReviewingAvailability('unknown', 'user_retry');
6438
+ syncReviewingTooltip();
6439
+ syncProbeStatus();
6440
+ }
5732
6441
  renderModelConfigChoices();
5733
6442
  updateModelDisplay();
6443
+ renderContextUsage();
5734
6444
  // Save feedback is per-card now (settingsPanel flashSaved); the global
5735
6445
  // header chip and its Saving/Saved lifecycle are gone.
5736
6446
  await saveState();
@@ -5817,21 +6527,64 @@
5817
6527
  return speedSelect?.value || state?.speedTier || 'standard';
5818
6528
  }
5819
6529
 
5820
-
5821
- let composerAttachmentsRestoredOnce = false;
6530
+ let composerDraftPersistence = null;
5822
6531
  let lastRunActivityBumpMs = 0;
5823
6532
 
6533
+ function getComposerDraftPersistence() {
6534
+ if (!composerDraftPersistence) composerDraftPersistence = Modules.ComposerAttachmentDraft.createController({
6535
+ getScope: () => ({ accountScopeId: cachedAccountScopeId, projectId: getCurrentProjectId() }),
6536
+ getAttachments: () => composerAttachmentController.serializePersistableAttachments(),
6537
+ setAttachments: value => { state.composerAttachments = value; },
6538
+ clear: () => composerAttachmentController.clear({ notify: false }),
6539
+ restore: value => composerAttachmentController.restorePersistedAttachments(value),
6540
+ read: scope => Modules.StorageDb.readComposerAttachmentDraft(scope),
6541
+ write: (scope, value) => Modules.StorageDb.writeComposerAttachmentDraft(scope, value),
6542
+ scheduleSave: () => saveStateSoon()
6543
+ });
6544
+ return composerDraftPersistence;
6545
+ }
6546
+
6547
+ async function loadComposerAttachmentsForProject(accountScopeId, projectId) {
6548
+ if (!accountScopeId || !projectId) return [];
6549
+ try {
6550
+ return await getComposerDraftPersistence().load({ accountScopeId, projectId });
6551
+ } catch (error) {
6552
+ if (error?.code === 'stale_hydration' || error?.code === 'account_scope_unavailable') throw error;
6553
+ appendPlainLog(tx('Saved attachments could not be restored. Reattach the files before sending.',
6554
+ '已保存的附件无法恢复,请在发送前重新添加文件。'));
6555
+ return [];
6556
+ }
6557
+ }
6558
+
5824
6559
  function persistComposerAttachmentsToState() {
5825
6560
  if (typeof composerAttachmentController?.serializePersistableAttachments === 'function') {
5826
6561
  state.composerAttachments = composerAttachmentController.serializePersistableAttachments();
5827
6562
  }
5828
6563
  }
5829
6564
 
5830
- function clearTaskComposer({ keepAttachments = false } = {}) {
6565
+ function clearTaskComposer({ keepAttachments = false, submission = null } = {}) {
5831
6566
  const taskInput = panel?.querySelector('[data-task]');
6567
+ if (submission) {
6568
+ if (submission.projectId !== getCurrentProjectId()
6569
+ || submission.accountScopeId !== cachedAccountScopeId) return false;
6570
+ if (findSessionById(submission.sessionId)?.task !== submission.task) return false;
6571
+ if (state.activeSessionId !== submission.sessionId) {
6572
+ updateSessionById(submission.sessionId, { task: '' });
6573
+ recordSubmittedDraftConsumption(submission.sessionId, submission.task);
6574
+ saveStateSoon();
6575
+ return false;
6576
+ }
6577
+ if (composerAttachmentController.getAttachmentByteUsage?.().pending > 0
6578
+ || selectionContextView.getRevision?.() !== submission.selectionRevision) return false;
6579
+ if (!runController.isSubmittedComposerUnchanged(submission, {
6580
+ task: taskInput?.value, attachments: getComposerAttachmentsForRun(),
6581
+ skillInvocation: getComposerSkillInvocationForRun(), selectionContext: selectionContextView.capture()
6582
+ })) return false;
6583
+ }
5832
6584
  if (taskInput) {
5833
6585
  taskInput.value = '';
5834
6586
  }
6587
+ composerDraftEditedHere = false;
5835
6588
  autosizeTaskTextarea();
5836
6589
  if (!keepAttachments) {
5837
6590
  composerAttachmentController.clear();
@@ -5840,10 +6593,47 @@
5840
6593
  renderComposerSkillInvocation();
5841
6594
  selectionContextView.clear();
5842
6595
  state = updateActiveSession(state, { task: '' });
6596
+ if (submission) recordSubmittedDraftConsumption(submission.sessionId, submission.task);
6597
+ else if (currentRunView?.sessionId === state.activeSessionId) delete currentRunView.draftConsumption;
6598
+ saveStateSoon();
6599
+ return true;
6600
+ }
6601
+
6602
+ function recordSubmittedDraftConsumption(sessionId, task) {
6603
+ if (task && currentRunView?.sessionId === sessionId
6604
+ && currentRunView.runProjectId === getCurrentProjectId()
6605
+ && currentRunView.runAccountScopeId === cachedAccountScopeId) {
6606
+ currentRunView.draftConsumption = { runId: currentRunView.recordId, task };
6607
+ }
6608
+ }
6609
+
6610
+ function restoreRejectedLocalSkillSubmission(submission, checkpoint) {
6611
+ const input = panel?.querySelector('[data-task]');
6612
+ if (!checkpoint || !input || submission.accountScopeId !== cachedAccountScopeId
6613
+ || submission.projectId !== getCurrentProjectId() || submission.sessionId !== state.activeSessionId
6614
+ || !findSessionById(submission.sessionId) || input.value || state.task
6615
+ || composerDraftRevision !== checkpoint.draftRevision
6616
+ || getComposerAttachmentsForRun().length || composerAttachmentController.getAttachmentByteUsage?.().pending
6617
+ || composerAttachmentController.getRevision?.() !== checkpoint.attachmentRevision
6618
+ || selectionContextView.getRevision?.() !== checkpoint.selectionRevision
6619
+ || selectionContextView.capture() || getComposerSkillInvocationForRun()) return false;
6620
+ if (!composerAttachmentController.restoreSubmittedAttachments?.(submission.attachments, checkpoint.attachmentRevision)) return false;
6621
+ composerDraftRevision += 1;
6622
+ composerSkillInvocation = submission.skillInvocation;
6623
+ input.value = submission.task;
6624
+ state = updateActiveSession(state, { task: submission.task, selectionContext: submission.selectionContext });
6625
+ renderComposerSkillInvocation();
6626
+ selectionContextView.render();
6627
+ syncProbeStatus();
6628
+ autosizeTaskTextarea();
6629
+ syncComposerSendAvailability();
5843
6630
  saveStateSoon();
6631
+ return true;
5844
6632
  }
5845
6633
 
5846
6634
  function applyStateToPanel(options = {}) {
6635
+ if (pendingDraftSave && pendingDraftSave.scope !== getComposerDraftScope()) resetDraftSave();
6636
+ contextUsageView?.close();
5847
6637
  selectionContextView.render();
5848
6638
  writingStyleSettings.sync();
5849
6639
  applyPanelTheme(getThemePreference());
@@ -5866,6 +6656,7 @@
5866
6656
  autosizeTaskTextarea();
5867
6657
  syncComposerSendAvailability();
5868
6658
  panel.querySelector('[data-require-reviewing]').checked = state.requireReviewing;
6659
+ syncReviewingTooltip();
5869
6660
  const recompileCheckbox = panel?.querySelector('[data-auto-recompile]');
5870
6661
  if (recompileCheckbox) {
5871
6662
  recompileCheckbox.checked = state.autoRecompile !== false;
@@ -5882,23 +6673,14 @@
5882
6673
  applySessionLabel();
5883
6674
  renderSessionList();
5884
6675
  renderRunHistory(options);
6676
+ renderPendingInputs();
5885
6677
  getTrackedChangeCaptureController().schedule();
5886
6678
  renderContextSelection();
5887
6679
  renderContextSummary();
5888
- // v1.8.0 C4: refresh recovery — small attachments persisted in state are
5889
- // restored into the (empty) tray ONCE per page load, before the render
5890
- // below. Re-running the gate on later renders would resurrect files the
5891
- // user explicitly removed (fleet P2).
5892
- if (!composerAttachmentsRestoredOnce
5893
- && Array.isArray(state.composerAttachments) && state.composerAttachments.length
5894
- && !composerAttachmentController.getAttachmentsForRun?.().length
5895
- && typeof composerAttachmentController.restorePersistedAttachments === 'function') {
5896
- composerAttachmentController.restorePersistedAttachments(state.composerAttachments);
5897
- }
5898
- composerAttachmentsRestoredOnce = true;
6680
+ getComposerDraftPersistence().hydrate(state.composerAttachments);
5899
6681
  renderComposerAttachments();
5900
6682
  renderComposerSkillInvocation();
5901
- applyLocaleToPanel();
6683
+ applyLocaleToPanel({ reprojectDynamic: false });
5902
6684
  if (!panel.querySelector('[data-context-tray]')?.hidden) {
5903
6685
  renderContextFiles();
5904
6686
  }
@@ -5929,6 +6711,47 @@
5929
6711
  if (record) configureWritebackButton(root, record, { running });
5930
6712
  });
5931
6713
  renderPendingInputs();
6714
+ renderContextUsage();
6715
+ }
6716
+
6717
+ function renderContextUsage() {
6718
+ if (!contextUsageView || !state) return;
6719
+ contextUsageView.setState(getContextUsageDisplay());
6720
+ }
6721
+
6722
+ function getContextUsageDisplay() {
6723
+ const session = getActiveSession(state);
6724
+ const run = currentRunView?.sessionId === session?.id
6725
+ ? findRunRecord(currentRunView.recordId, currentRunView.sessionId) : null;
6726
+ const selection = providerSettingsCoordinator.getRunSelection(state.providerId);
6727
+ const runningSelection = run?.status === 'running' && run.model === state.model
6728
+ && (run.providerId || 'builtin') === (state.providerId || 'builtin');
6729
+ const scope = {
6730
+ threadId: session?.codexThreadId || '',
6731
+ providerId: state.providerId || 'builtin',
6732
+ providerRevision: runningSelection ? Number(run.providerRevision || 0)
6733
+ : selection?.providerRevision ?? ((state.providerId || 'builtin') === 'builtin' ? 0 : null),
6734
+ model: state.model || ''
6735
+ };
6736
+ // The composer can select the next turn's model while this turn runs.
6737
+ // Only show statistics produced by the selected model and provider.
6738
+ const matchesRun = run && run.model === scope.model
6739
+ && (run.providerId || 'builtin') === scope.providerId
6740
+ && Number(run.providerRevision || 0) === scope.providerRevision;
6741
+ // Starting another turn must not revive statistics from before an automatic
6742
+ // compaction. Retain the watermark across this thread's saved normal runs.
6743
+ const compactedAt = (session?.runs || []).reduce((latest, candidate) => candidate && !candidate.forkSnapshot
6744
+ && candidate.operation !== 'compact' && scope.threadId && candidate.codexThreadId === scope.threadId
6745
+ ? Math.max(latest, Modules.RunActivityModel.getCompactedAt(candidate)) : latest, 0);
6746
+ const { usage, pending, compacted } = Modules.ContextUsage.status(session, { ...scope, compactedAt });
6747
+ const compacting = Boolean(matchesRun && Modules.RunActivityModel.project(run).compacting);
6748
+ return { usage, pending, compacted, compacting, compactingKind: compacting ? (run.operation === 'compact' ? 'manual' : 'auto') : '' };
6749
+ }
6750
+
6751
+ function getCompactAvailability() {
6752
+ if (sessionManager.isTransitioning() || updateIdleClient?.isApplying() || currentRunView || runSubmissionPending) return { ok: false, reason: 'compactBusy' };
6753
+ if (!getActiveSession(state)?.codexThreadId) return { ok: false, reason: 'compactNoThread' };
6754
+ return { ok: true };
5932
6755
  }
5933
6756
 
5934
6757
  function prepareRetryReplacement(run, task) {
@@ -5959,6 +6782,10 @@
5959
6782
  }
5960
6783
 
5961
6784
  function startRunView({
6785
+ sessionId = state.activeSessionId,
6786
+ sessionContext = runController.captureSessionContext(state),
6787
+ codexThreadId = '',
6788
+ operation,
5962
6789
  task,
5963
6790
  mode,
5964
6791
  model,
@@ -5975,6 +6802,7 @@
5975
6802
  resetAutoFollow();
5976
6803
  const record = {
5977
6804
  id: createRunId(),
6805
+ ...(operation === 'compact' ? { operation } : {}),
5978
6806
  task,
5979
6807
  mode,
5980
6808
  ...providerSettingsCoordinator.getProviderSnapshot(executionSnapshot?.providerId || state?.providerId),
@@ -5999,63 +6827,54 @@
5999
6827
  executionSnapshot: executionSnapshot
6000
6828
  ? RunExecutionSnapshot.cloneSnapshot(executionSnapshot)
6001
6829
  : undefined,
6002
- // Welcome-panel + write-guard:
6003
- // Immutable per-run capture of the project this run was submitted
6004
- // against. Every writeback / accept / undo dispatch attaches this id
6005
- // to its page-bridge params so the page-side guard can verify the run
6006
- // is still acting on the same project before mutating the editor. No
6007
- // language-level enforcement — the field is treated as immutable by
6008
- // convention and never reassigned anywhere downstream.
6830
+ // Writeback/Accept/Undo carry this immutable project identity to Page.
6009
6831
  runProjectId: getCurrentProjectId() || '',
6010
6832
  runAccountScopeId: cachedAccountScopeId || ''
6011
6833
  };
6012
- const active = getActiveSession(state);
6013
- const titlePatch = active?.titleSource !== 'manual'
6834
+ const active = findSessionById(sessionId);
6835
+ const titlePatch = operation !== 'compact' && active?.titleSource !== 'manual'
6014
6836
  ? {
6015
6837
  title: deriveSessionTitle([], task),
6016
6838
  titleSource: 'auto'
6017
6839
  }
6018
6840
  : {};
6019
- state = updateActiveSession(state, {
6020
- runs: runController.replaceRunForRetry(state.runs, replaceRunId, record),
6841
+ // Establish the in-memory owner before publishing or rendering the record.
6842
+ // If presentation throws, runTask still has the identity needed to settle it.
6843
+ const view = currentRunView = {
6844
+ operation, sessionId, sessionContext, codexThreadId, recordId: record.id,
6845
+ runProjectId: record.runProjectId, runAccountScopeId: record.runAccountScopeId,
6846
+ executionSnapshot: record.executionSnapshot, startedAt: Date.now(), queueItemId,
6847
+ nativeRequestId: '', activeTurn: null
6848
+ };
6849
+ updateSessionById(sessionId, {
6850
+ runs: runController.replaceRunForRetry(active?.runs, replaceRunId, record),
6021
6851
  ...titlePatch
6022
6852
  });
6023
6853
  saveStateSoon(replaceRunId ? 0 : 120);
6024
6854
 
6025
6855
  const log = panel.querySelector('[data-log]');
6026
- removeEmptyRunsMessage(log);
6027
- if (replaceRunId) log.querySelector(`[data-run-id="${cssEscape(replaceRunId)}"]`)?.remove();
6028
6856
  const root = renderRunCard(record);
6029
- log.append(root);
6030
- scrollLogToBottom({ force: true });
6031
- startRunElapsedTick();
6032
- renderSessionList();
6033
- applySessionLabel();
6034
-
6035
- return {
6036
- sessionId: state.activeSessionId,
6037
- recordId: record.id,
6038
- // Mirror the immutable runProjectId on the view so writeback /
6039
- // accept / undo dispatches don't need to re-look up the record.
6040
- runProjectId: record.runProjectId,
6041
- runAccountScopeId: record.runAccountScopeId,
6042
- executionSnapshot: record.executionSnapshot,
6857
+ Object.assign(view, {
6043
6858
  root,
6044
6859
  runProcess: root.querySelector('[data-run-process]'),
6045
6860
  processLabel: root.querySelector('[data-run-process-summary]'),
6046
6861
  events: root.querySelector('[data-run-events]'),
6047
6862
  report: root.querySelector('[data-run-report]'),
6048
6863
  status: root.querySelector('[data-run-status]'),
6049
- // A run starts before warm-mirror resolution has produced its lightweight
6050
- // project snapshot. Seed references from the already-prefetched project
6051
- // inventory so early stream events and fast Ask responses can still
6052
- // resolve project-relative locations.
6053
- projectFiles: getCurrentProjectReferenceFiles(),
6054
- startedAt: Date.now(),
6055
- queueItemId,
6056
- nativeRequestId: '',
6057
- activeTurn: null
6058
- };
6864
+ // Seed references before warm-mirror resolution produces its snapshot.
6865
+ projectFiles: getCurrentProjectReferenceFiles()
6866
+ });
6867
+ if (state.activeSessionId === sessionId) {
6868
+ removeEmptyRunsMessage(log);
6869
+ if (replaceRunId) log.querySelector(`[data-run-id="${cssEscape(replaceRunId)}"]`)?.remove();
6870
+ log.append(root);
6871
+ scrollLogToBottom({ force: true });
6872
+ }
6873
+ startRunElapsedTick();
6874
+ renderSessionList();
6875
+ applySessionLabel();
6876
+
6877
+ return view;
6059
6878
  }
6060
6879
 
6061
6880
  function touchSessionForTerminalRun(sessionId, finishedAt) {
@@ -6118,6 +6937,7 @@
6118
6937
  ].filter(Boolean).join(' · ');
6119
6938
  collapseRunProcess(visibleView, statusText);
6120
6939
  }
6940
+ renderContextUsage();
6121
6941
  }
6122
6942
 
6123
6943
 
@@ -6132,6 +6952,11 @@
6132
6952
  }
6133
6953
 
6134
6954
  const owner = currentRunView && findRunRecord(currentRunView.recordId, currentRunView.sessionId);
6955
+ if (storeContextUsage(event, owner, findSessionById(currentRunView?.sessionId), journalSeq)) {
6956
+ scheduleRunStateSave('stream');
6957
+ renderContextUsage();
6958
+ return;
6959
+ }
6135
6960
  if (owner && Modules.SubagentActivityModel.ingest(owner, event, {
6136
6961
  locale: getLocale(), sanitize: sanitizeAssistantVisibleText, nativeEventSeq: journalSeq
6137
6962
  })) {
@@ -6160,6 +6985,20 @@
6160
6985
  replaceText: activity.replaceText,
6161
6986
  nativeEventSeq: journalSeq
6162
6987
  });
6988
+ if (activity.kind !== 'stream') renderContextUsage();
6989
+ }
6990
+
6991
+ function storeContextUsage(event, record, session, sequence) {
6992
+ if (!record) return false;
6993
+ const usage = Modules.ContextUsage.fromEvent(event, {
6994
+ ...record, providerRevision: Number(record.providerRevision || 0), eventSeq: sequence
6995
+ });
6996
+ if (!usage || !Modules.ContextUsage.isNewer(usage, record.contextUsage)) return false;
6997
+ record.contextUsage = usage;
6998
+ record.nativeEventSeq = Math.max(Number(record.nativeEventSeq || 0), Number(sequence || 0));
6999
+ if (session?.codexThreadId === usage.threadId
7000
+ && Modules.ContextUsage.isNewer(usage, session.contextUsage)) session.contextUsage = usage;
7001
+ return true;
6163
7002
  }
6164
7003
 
6165
7004
  function bindActiveTurn(detail = {}, journalSeq = 0) {
@@ -6168,16 +7007,24 @@
6168
7007
  }
6169
7008
  const threadId = String(detail.threadId || '');
6170
7009
  const turnId = String(detail.turnId || '');
6171
- currentRunView.activeTurn = { threadId, turnId };
7010
+ currentRunView.activeTurn = currentRunView.operation === 'compact' ? null : { threadId, turnId };
6172
7011
  const record = findRunRecord(currentRunView.recordId, currentRunView.sessionId);
6173
7012
  if (record) {
6174
7013
  record.codexThreadId = threadId; record.codexTurnId = turnId;
7014
+ if (record.operation === 'compact') {
7015
+ const session = findSessionById(currentRunView.sessionId);
7016
+ if (session?.contextUsage?.threadId === threadId
7017
+ && Date.parse(session.contextUsage.observedAt) < Date.parse(record.startedAt)) {
7018
+ session.contextUsage = null;
7019
+ }
7020
+ }
6175
7021
  record.nativeEventSeq = Math.max(Number(record.nativeEventSeq || 0), Number(journalSeq || 0));
6176
7022
  }
6177
7023
  if (threadId) {
6178
7024
  updateSessionById(currentRunView.sessionId, { codexThreadId: threadId });
6179
7025
  }
6180
7026
  renderPendingInputs();
7027
+ renderContextUsage();
6181
7028
  saveState().catch(() => {});
6182
7029
  }
6183
7030
 
@@ -6198,7 +7045,7 @@
6198
7045
  function appendRunEvent(input = {}) {
6199
7046
  const { title, status = 'info', detail, timestamp } = input;
6200
7047
  const safeTitle = sanitizeAssistantVisibleText(title) || 'Event';
6201
- if (!currentRunView?.events) {
7048
+ if (!currentRunView) {
6202
7049
  appendPlainLog(safeTitle);
6203
7050
  return;
6204
7051
  }
@@ -6242,21 +7089,20 @@
6242
7089
  })).filter(item => item.path)
6243
7090
  : undefined
6244
7091
  };
7092
+ // Keep the activity timestamp current without retaining a run reference
7093
+ // across session normalization, which replaces run and event objects.
7094
+ const nowMs = Date.now();
7095
+ if (nowMs - lastRunActivityBumpMs > 60000
7096
+ && findRunRecord(currentRunView.recordId, currentRunView.sessionId)) {
7097
+ lastRunActivityBumpMs = nowMs;
7098
+ state = updateActiveSession(state, {});
7099
+ }
6245
7100
  const record = findRunRecord(currentRunView.recordId, currentRunView.sessionId);
6246
7101
  let renderedEvent = event;
6247
7102
  if (record) {
6248
7103
  if (Number.isFinite(Number(input.nativeEventSeq))) {
6249
7104
  record.nativeEventSeq = Math.max(Number(record.nativeEventSeq || 0), Number(input.nativeEventSeq || 0));
6250
7105
  }
6251
- // v1.8.1: keep the session's activity timestamp honest DURING a run —
6252
- // it was pinned to run start, so the dashboard's 30-minute zombie
6253
- // heuristic mislabeled any long live run as interrupted (fleet P1).
6254
- // Throttled to once a minute; updateActiveSession stamps updatedAt.
6255
- const nowMs = Date.now();
6256
- if (nowMs - lastRunActivityBumpMs > 60000) {
6257
- lastRunActivityBumpMs = nowMs;
6258
- state = updateActiveSession(state, {});
6259
- }
6260
7106
  renderedEvent = event.kind === 'stream'
6261
7107
  ? upsertRunStreamRecordEvent(record, event)
6262
7108
  : event;
@@ -6592,311 +7438,34 @@
6592
7438
  );
6593
7439
  }
6594
7440
 
6595
- async function undoRun(runId) {
6596
- const run = findRunRecord(runId);
6597
- if (!getRunUndoCount(run) || run.undoStatus === 'applied') {
6598
- return;
6599
- }
6600
- // Wait out any background mirror refresh before undoing (v1.7.5): a
6601
- // mirror.sync finishing after the undo would push the pre-undo snapshot
6602
- // as the local baseline and resurrect the undone content next run.
6603
- const pendingMirror = getPendingMirrorRefresh?.();
6604
- if (pendingMirror) {
6605
- await pendingMirror.catch(() => {});
6606
- }
6607
-
6608
- if ((Array.isArray(run.undoTrackedChanges) && run.undoTrackedChanges.length) || hasTrackedEditorUndo(run)
6609
- || (run.trackedChangeStatus === 'needs_review' && run.undoOperations?.some(operation => operation?.undoCreatedFile?.v === 1))) {
6610
- await undoRunTrackedChanges(runId, run);
6611
- return;
6612
- }
6613
-
6614
- const undoRestore = buildNoTraceUndoRestore(run);
6615
- const undoOperations = undoRestore.operations;
6616
- const unsafeFullFileUndo = findUnsafeFullFileUndoOperation(undoOperations, {
6617
- allowSnapshotRestore: undoRestore.snapshotRestore
6618
- });
6619
- if (unsafeFullFileUndo) {
6620
- appendRunRecordEvent(runId, {
6621
- title: tr('undoUnsafeFullFileTitle'),
6622
- status: 'failed',
6623
- detail: {
6624
- [tr('detailFile')]: unsafeFullFileUndo.path,
6625
- [tr('detailReason')]: tr('undoUnsafeFullFileReason')
6626
- }
6627
- });
6628
- return;
6629
- }
6630
-
6631
- // v1.8.0 C3: multi-file undos confirm through a per-file selection —
6632
- // undoing a subset leaves undoStatus 'partial' and the button usable;
6633
- // re-undoing an already-restored file is skipped by the base check.
6634
- const undoPaths = Array.from(new Set(undoOperations.map(operation => operation?.path).filter(Boolean)));
6635
- let selectedOperations = undoOperations;
6636
- if (undoPaths.length > 1 && typeof showUndoFileSelection === 'function') {
6637
- const selectedPaths = await showUndoFileSelection({
6638
- title: tr('undoFileSelectTitle'),
6639
- task: truncateRunTitle(run.task),
6640
- confirmLabel: tr('undoFileSelectConfirm'),
6641
- cancelLabel: tr('confirmDefaultCancel'),
6642
- selectAllLabel: tr('undoFileSelectAll'),
6643
- paths: undoPaths
6644
- });
6645
- if (!selectedPaths) {
6646
- return;
6647
- }
6648
- const selectedSet = new Set(selectedPaths);
6649
- selectedOperations = undoOperations.filter(operation => selectedSet.has(operation?.path));
6650
- } else {
6651
- const approved = await showPluginConfirm({
6652
- title: tr('undoNoTraceTitle'),
6653
- message: [
6654
- truncateRunTitle(run.task),
6655
- '',
6656
- tr('undoNoTraceMessage', { files: formatOperationFiles(undoOperations) })
6657
- ].join('\n'),
6658
- confirmLabel: tr('undoConfirm'),
6659
- cancelLabel: tr('confirmDefaultCancel'),
6660
- destructive: true
6661
- });
6662
- if (!approved) {
6663
- return;
6664
- }
6665
- }
6666
-
6667
- const deadlineAt = Date.now() + getPageBridgeTimeoutMs('rejectTrackedChanges');
6668
- setRunUndoStatus(runId, 'running');
6669
- appendRunRecordEvent(runId, {
6670
- title: tr('undoNoTraceStarted'),
6671
- status: 'running',
6672
- detail: { [tr('detailWillUndo')]: formatOperationFiles(selectedOperations) }
7441
+ let runReviewController = null;
7442
+ function getRunReviewController() {
7443
+ if (!runReviewController) runReviewController = Modules.RunReviewController.create({
7444
+ Modules, WritebackSettlement, trackedChangeInFlight, findRunRecord,
7445
+ getRunUndoCount, isTrackedChangeLifecycleRun, getPendingMirrorRefresh, hasTrackedEditorUndo, buildNoTraceUndoRestore,
7446
+ findUnsafeFullFileUndoOperation, showUndoFileSelection, showPluginConfirm, tr,
7447
+ truncateRunTitle, formatOperationFiles, formatTrackedChangeFiles, formatTrackedUndoFiles,
7448
+ getPageBridgeTimeoutMs, callPageBridge, setRunUndoStatus, appendRunRecordEvent, getRunProjectIdForWriteback,
7449
+ isUndoResultEffectivelyApplied, writebackOrchestrator, appendUndoReviewingPolicyEvent, formatOperationType,
7450
+ formatApplyResultReason, buildTrackedUndoPostFiles, getTrackedChangeCaptureController, buildContentFailure,
7451
+ applyTrackedChangeSettlement, refreshRunCardControls, formatBridgeResultReason, advanceRunUndoProgress,
7452
+ saveStateSoon, flushQueuedSaveState,
7453
+ getState: () => state, getCurrentProjectId, getAccountScopeId: () => cachedAccountScopeId,
7454
+ getRouteGeneration: () => spaRouteGeneration, tx, notify: message => showPluginToast(message, { status: 'warning', sticky: true })
6673
7455
  });
7456
+ return runReviewController;
7457
+ }
6674
7458
 
6675
- const result = await callPageBridge('applyOperations', {
6676
- operations: selectedOperations, deadlineAt,
6677
- baseFiles: run.undoBaseFiles || [],
6678
- reviewingPolicy: 'no-trace-undo',
6679
- // Undo remains bound to its original project, including after navigation.
6680
- runProjectId: getRunProjectIdForWriteback(run)
6681
- });
6682
- const undoApplied = isUndoResultEffectivelyApplied(run, result);
6683
- writebackOrchestrator.invalidateMirrorAfterUndo(runId, getRunProjectIdForWriteback(run), result);
6684
- appendUndoReviewingPolicyEvent(runId, result.reviewingPolicy);
6685
- appendRunRecordEvent(runId, {
6686
- title: tr('undoResult', { applied: result.applied?.length || 0, skipped: result.skipped?.length || 0 }),
6687
- status: undoApplied ? 'completed' : result.skipped?.length ? 'failed' : 'completed',
6688
- detail: {
6689
- [tr('detailUndone')]: (result.applied || []).map(item => ({
6690
- [tr('detailAction')]: formatOperationType(item.operation?.type),
6691
- [tr('detailFile')]: item.operation?.path
6692
- })),
6693
- [tr('detailSkipped')]: (result.skipped || []).map(item => ({
6694
- [tr('detailAction')]: formatOperationType(item.operation?.type),
6695
- [tr('detailFile')]: item.operation?.path,
6696
- [tr('detailReason')]: formatApplyResultReason(item)
6697
- }))
6698
- }
6699
- });
6700
- // A subset undo never closes the ledger: unselected files can still
6701
- // be undone later, so the run stays 'partial' and the button usable.
6702
- const fullSelection = selectedOperations.length === undoOperations.length;
6703
- applyLegacyUndoSettlement(
6704
- runId,
6705
- undoApplied && fullSelection ? 'applied' : 'partial',
6706
- result
6707
- );
7459
+ async function undoRun(runId) {
7460
+ return getRunReviewController().undoRun(runId);
6708
7461
  }
6709
7462
 
6710
7463
  async function undoRunTrackedChanges(runId, run) {
6711
- const createdUndoOperations = (run.undoOperations || []).filter(operation => operation?.undoCreatedFile?.v === 1);
6712
- const trackedUndo = Array.isArray(run.undoTrackedChanges) && run.undoTrackedChanges.length > 0;
6713
- const hasTextUndo = trackedUndo || buildTrackedUndoPostFiles(run).length > 0;
6714
- const approved = await showPluginConfirm({
6715
- title: !hasTextUndo ? tr('undoNoTraceTitle') : trackedUndo ? tr('undoTrackedTitle') : tr('undoNativeTitle'),
6716
- message: [
6717
- truncateRunTitle(run.task),
6718
- '',
6719
- ...(hasTextUndo ? [trackedUndo
6720
- ? tr('undoTrackedMessage', { files: formatTrackedChangeFiles(run.undoTrackedChanges) })
6721
- : tr('undoNativeMessage', { files: formatTrackedUndoFiles(run) })] : []),
6722
- ...(createdUndoOperations.length ? [tr('undoNoTraceMessage', { files: formatOperationFiles(createdUndoOperations) })] : [])
6723
- ].join('\n'),
6724
- confirmLabel: trackedUndo ? tr('undoTrackedConfirm') : tr('undoConfirm'),
6725
- cancelLabel: tr('confirmDefaultCancel'),
6726
- destructive: true
6727
- });
6728
- if (!approved) {
6729
- return;
6730
- }
6731
-
6732
- const deadlineAt = Date.now() + getPageBridgeTimeoutMs('rejectTrackedChanges');
6733
- // Keep the review lifecycle until every pending part has settled.
6734
- // Its local lock stays active while verified recovery progress is persisted.
6735
- const lifecycleReject = (trackedUndo || createdUndoOperations.length > 0) && isTrackedChangeLifecycleRun(run);
6736
- if (lifecycleReject) {
6737
- trackedChangeInFlight.set(runId, 'reject');
6738
- refreshRunCardControls(runId);
6739
- } else {
6740
- setRunUndoStatus(runId, 'running');
6741
- }
6742
- appendRunRecordEvent(runId, {
6743
- title: !hasTextUndo ? tr('undoNoTraceStarted') : trackedUndo ? tr('undoTrackedStarted') : tr('undoNativeStarted'),
6744
- status: 'running',
6745
- detail: { [tr('detailWillUndo')]: !hasTextUndo ? formatOperationFiles(createdUndoOperations)
6746
- : trackedUndo ? formatTrackedChangeFiles(run.undoTrackedChanges) : formatTrackedUndoFiles(run) }
6747
- });
6748
-
6749
- try {
6750
- const trackedUndoPostFilesAtDispatch = buildTrackedUndoPostFiles(run);
6751
- let result = hasTextUndo ? await callPageBridge('rejectTrackedChanges', {
6752
- trackedChanges: run.undoTrackedChanges || [], deadlineAt,
6753
- expectedFiles: run.undoExpectedFiles || [],
6754
- postFiles: trackedUndoPostFilesAtDispatch,
6755
- untrackedUndo: run.executionSnapshot
6756
- ? run.executionSnapshot.requireReviewing === false && ['submitted', 'legacy-captured'].includes(run.executionSnapshot.source)
6757
- : getTrackedChangeCaptureController().hasLegacyUntrackedCheckpoint(run),
6758
- // The page-side project guard still refuses writes after navigation.
6759
- runProjectId: getRunProjectIdForWriteback(run)
6760
- }) : { ok: true, applied: [], skipped: [] };
6761
- if (createdUndoOperations.length && lifecycleReject && hasTextUndo) {
6762
- WritebackSettlement.attachUndoNotVerifiedFailure(run, result, { buildFailure: buildContentFailure, postFiles: trackedUndoPostFilesAtDispatch });
6763
- await applyTrackedChangeSettlement(runId, 'reject', { ...result, ok: false });
6764
- }
6765
- if (createdUndoOperations.length) {
6766
- let createdResult;
6767
- try {
6768
- if (result.ok === false || result.skipped?.length) {
6769
- throw new Error('Text changes remain unresolved; created files were kept for the next undo attempt.');
6770
- }
6771
- createdResult = await callPageBridge('applyOperations', {
6772
- operations: createdUndoOperations, deadlineAt,
6773
- baseFiles: run.undoBaseFiles || [],
6774
- reviewingPolicy: 'no-trace-undo',
6775
- runProjectId: getRunProjectIdForWriteback(run)
6776
- });
6777
- } catch (error) {
6778
- createdResult = { ok: false, applied: [], skipped: createdUndoOperations.map(operation => ({
6779
- operation, result: { ok: false, code: 'undo_operation_failed', reason: error.message }
6780
- })) };
6781
- }
6782
- const applied = [...(result.applied || []), ...(createdResult.applied || [])];
6783
- const skipped = [...(result.skipped || []), ...(createdResult.skipped || [])];
6784
- for (const operation of createdUndoOperations) {
6785
- if (!(createdResult.applied || []).some(entry => entry.operation?.path === operation.path
6786
- && entry.result?.ok === true && entry.result?.verified === true)
6787
- && !(createdResult.skipped || []).some(entry => entry.operation?.path === operation.path)) {
6788
- skipped.push({ operation, result: { ok: false, code: 'undo_operation_failed',
6789
- reason: 'The created file has no verified deletion receipt.' } });
6790
- }
6791
- }
6792
- result = { ...result, ok: result.ok !== false && createdResult.ok !== false && !skipped.length, applied, skipped };
6793
- }
6794
- writebackOrchestrator.invalidateMirrorAfterUndo(runId, getRunProjectIdForWriteback(run), result);
6795
- appendRunRecordEvent(runId, {
6796
- title: createdUndoOperations.length ? tr('undoResult', { applied: result.applied?.length || 0, skipped: result.skipped?.length || 0 }) : trackedUndo
6797
- ? tr('undoTrackedResult', { applied: result.applied?.length || 0, skipped: result.skipped?.length || 0 })
6798
- : tr('undoNativeResult', { applied: result.applied?.length || 0, skipped: result.skipped?.length || 0 }),
6799
- status: result.skipped?.length ? 'failed' : 'completed',
6800
- detail: {
6801
- [trackedUndo && !createdUndoOperations.length ? tr('detailRejected') : tr('detailUndone')]: (result.applied || []).map(item => ({
6802
- [tr('detailFile')]: item.trackedChange?.path || item.operation?.path || tr('unknownFile'),
6803
- [tr('detailRecord')]: item.trackedChange?.label || item.trackedChange?.id || item.trackedChange?.key || item.operation?.type
6804
- })),
6805
- [tr('detailSkipped')]: (result.skipped || []).map(item => ({
6806
- [tr('detailFile')]: item.trackedChange?.path || item.operation?.path || tr('unknownFile'),
6807
- [tr('detailRecord')]: item.trackedChange?.label || item.trackedChange?.id || item.trackedChange?.key || item.operation?.type || '',
6808
- [tr('detailReason')]: formatBridgeResultReason(item.result, item.trackedChange?.path || item.operation?.path)
6809
- }))
6810
- }
6811
- });
6812
- if (lifecycleReject) {
6813
- WritebackSettlement.attachUndoNotVerifiedFailure(run, result, {
6814
- buildFailure: buildContentFailure,
6815
- postFiles: trackedUndoPostFilesAtDispatch
6816
- });
6817
- await applyTrackedChangeSettlement(runId, 'reject', result);
6818
- return;
6819
- }
6820
- applyLegacyUndoSettlement(runId, result.skipped?.length ? 'partial' : 'applied', result);
6821
- } finally {
6822
- if (lifecycleReject) {
6823
- trackedChangeInFlight.delete(runId);
6824
- refreshRunCardControls(runId);
6825
- }
6826
- }
7464
+ return getRunReviewController().undoRunTrackedChanges(runId, run);
6827
7465
  }
6828
7466
 
6829
7467
  async function acceptRun(runId) {
6830
- const run = findRunRecord(runId);
6831
- if (!run || !isTrackedChangeLifecycleRun(run)) {
6832
- return;
6833
- }
6834
- const status = run.trackedChangeStatus || '';
6835
- // pending and needs_review are both actionable per §7: needs_review means
6836
- // the prior attempt could not prove a clean post-action state, and the
6837
- // user is supposed to be able to retry after inspecting Overleaf.
6838
- if (status !== 'pending' && status !== 'needs_review') {
6839
- return;
6840
- }
6841
- if (!Array.isArray(run.undoTrackedChanges) || !run.undoTrackedChanges.length) {
6842
- return;
6843
- }
6844
- if (trackedChangeInFlight.get(runId)) {
6845
- return;
6846
- }
6847
-
6848
- trackedChangeInFlight.set(runId, 'accept');
6849
- refreshRunCardControls(runId);
6850
- appendRunRecordEvent(runId, {
6851
- title: tr('runAcceptTrackedStarted'),
6852
- status: 'running',
6853
- detail: { [tr('detailAccepted')]: formatTrackedChangeFiles(run.undoTrackedChanges) }
6854
- });
6855
-
6856
- try {
6857
- const result = await callPageBridge('acceptTrackedChanges', {
6858
- trackedChanges: run.undoTrackedChanges || [],
6859
- expectedFiles: run.undoExpectedFiles || [],
6860
- postFiles: buildTrackedUndoPostFiles(run),
6861
- appliedOperations: Array.isArray(run.appliedOperations) ? run.appliedOperations : [],
6862
- runProjectId: getRunProjectIdForWriteback(run)
6863
- });
6864
- appendAcceptDiagnosticEvents(runId, Array.isArray(result.diagnostics) ? result.diagnostics : []);
6865
- appendRunRecordEvent(runId, {
6866
- title: tr('runAcceptTrackedResult', { applied: result.applied?.length || 0, skipped: result.skipped?.length || 0 }),
6867
- status: result.skipped?.length ? 'failed' : 'completed',
6868
- detail: {
6869
- [tr('detailAccepted')]: (result.applied || []).map(item => ({
6870
- [tr('detailFile')]: item.trackedChange?.path || tr('unknownFile'),
6871
- [tr('detailRecord')]: item.trackedChange?.label || item.trackedChange?.id || item.trackedChange?.key
6872
- })),
6873
- [tr('detailSkipped')]: (result.skipped || []).map(item => ({
6874
- [tr('detailFile')]: item.trackedChange?.path || tr('unknownFile'),
6875
- [tr('detailRecord')]: item.trackedChange?.label || item.trackedChange?.id || item.trackedChange?.key || '',
6876
- [tr('detailReason')]: formatBridgeResultReason(item.result, item.trackedChange?.path)
6877
- }))
6878
- }
6879
- });
6880
- if (result.ok === false) {
6881
- appendRunRecordEvent(runId, {
6882
- title: tr('runAcceptTrackedFailed'),
6883
- status: 'failed',
6884
- detail: {
6885
- [tr('detailReason')]: tr('runAcceptTrackedFailedReason')
6886
- }
6887
- });
6888
- if (!WritebackSettlement.isSuccessfulTrackedChangeSettlement(result)) {
6889
- refreshRunCardControls(runId);
6890
- }
6891
- }
6892
- WritebackSettlement.attachAcceptNotVerifiedFailure(run, result, {
6893
- buildFailure: buildContentFailure
6894
- });
6895
- await applyTrackedChangeSettlement(runId, 'accept', result);
6896
- } finally {
6897
- trackedChangeInFlight.delete(runId);
6898
- refreshRunCardControls(runId);
6899
- }
7468
+ return getRunReviewController().acceptRun(runId);
6900
7469
  }
6901
7470
 
6902
7471
  function getRunProjectIdForWriteback(run) {
@@ -6905,32 +7474,6 @@
6905
7474
  : '';
6906
7475
  }
6907
7476
 
6908
- // Translates the page bridge's Accept All `diagnostics` array into one
6909
- // run-card event row per step. Each entry is `{ step, info }`; the step
6910
- // names map 1:1 to the runAcceptTrackedStep* i18n keys. We render `info`
6911
- // as the event detail so the user can copy-paste the full trace without
6912
- // any extra page-state query.
6913
- function appendAcceptDiagnosticEvents(runId, diagnostics) {
6914
- for (const entry of diagnostics || []) {
6915
- const step = entry && entry.step;
6916
- const info = (entry && entry.info) || {};
6917
- const titleKey = WritebackSettlement.ACCEPT_DIAGNOSTIC_TITLE_KEYS[step];
6918
- if (!titleKey) {
6919
- continue;
6920
- }
6921
- const title = titleKey === 'runAcceptTrackedStepReplayStart' || titleKey === 'runAcceptTrackedStepReplayDone'
6922
- ? tr(titleKey, { path: info.path || tr('unknownFile') })
6923
- : tr(titleKey);
6924
- const status = WritebackSettlement.acceptDiagnosticStatus(step, info);
6925
- appendRunRecordEvent(runId, {
6926
- title,
6927
- kind: 'activity',
6928
- status,
6929
- detail: info
6930
- });
6931
- }
6932
- }
6933
-
6934
7477
  function buildContentFailure(code, operation, overrides) {
6935
7478
  return WritebackSettlement.buildContentFailure(
6936
7479
  code,
@@ -6940,27 +7483,29 @@
6940
7483
  );
6941
7484
  }
6942
7485
 
6943
- async function applyTrackedChangeSettlement(runId, kind, result) {
7486
+ async function applyTrackedChangeSettlement(runId, kind, result, options = {}) {
6944
7487
  const record = findRunRecord(runId);
6945
- const createdUndo = record?.undoOperations?.some(operation => operation?.undoCreatedFile?.v === 1);
6946
- const run = kind === 'reject' && createdUndo ? advanceRunUndoProgress(record, result) : record;
7488
+ const run = kind === 'reject' ? advanceRunUndoProgress(record, result) : record;
6947
7489
  const settlement = WritebackSettlement.settleTrackedChangeLifecycle({
6948
7490
  kind,
6949
7491
  run,
6950
7492
  result,
6951
7493
  failureReasons: FailureReasons
6952
7494
  });
6953
- if (settlement.decision === 'blocked' && !(kind === 'reject' && createdUndo)) {
7495
+ if (settlement.decision === 'blocked'
7496
+ && !(kind === 'reject' && (run !== record || record?.undoOperations?.some(Modules.UndoOperations.isFileUndoOperation)))) {
6954
7497
  return;
6955
7498
  }
6956
7499
  await Modules.ScopedPersistenceCoordinator.applyReviewTransition({
6957
7500
  getState: () => state, runId, transition: WritebackSettlement.applySettlementTransition(run, settlement),
6958
- persist: flushQueuedSaveState
7501
+ persist: flushQueuedSaveState, assertCurrent: options.assertCurrent, SessionState: Modules.SessionState
6959
7502
  });
6960
7503
  }
6961
7504
 
6962
7505
 
6963
7506
  function advanceRunUndoProgress(run, result) {
7507
+ getDirectoryCleanupRecovery().capture({ recordId: run.id, sessionId: state.activeSessionId,
7508
+ runProjectId: run.runProjectId, runAccountScopeId: run.runAccountScopeId }, result);
6964
7509
  const postFiles = buildTrackedUndoPostFiles(run);
6965
7510
  return Modules.UndoOperations.advanceUndoCheckpoint(run, result, file =>
6966
7511
  WritebackSettlement.isUndoVerifiedContentMatching({ undoExpectedFiles: [file] }, result, { postFiles })
@@ -6970,11 +7515,12 @@
6970
7515
  if (!run) {
6971
7516
  return 0;
6972
7517
  }
7518
+ run = Modules.UndoOperations.withAvailableUndo(run);
6973
7519
  const trackedEditorUndoCount = hasTrackedEditorUndo(run) ? 1 : 0;
6974
7520
  if (trackedEditorUndoCount) {
6975
- return trackedEditorUndoCount + (run.undoOperations || []).filter(operation => operation?.undoCreatedFile?.v === 1).length;
7521
+ return trackedEditorUndoCount + Modules.UndoOperations.getFileUndoOperations(run).length;
6976
7522
  }
6977
- return (run.undoOperations?.length || 0)
7523
+ return Modules.UndoOperations.prepareRunUndo(run).undoOperations.length
6978
7524
  + (run.undoTrackedChanges?.length || 0);
6979
7525
  }
6980
7526
 
@@ -6986,29 +7532,55 @@
6986
7532
  sessionId: target.sessionId, runId: target.recordId, createdAt: new Date().toISOString() };
6987
7533
  }
6988
7534
 
7535
+ let directoryCleanupRecovery = null;
7536
+ function getDirectoryCleanupRecovery() {
7537
+ return directoryCleanupRecovery || (directoryCleanupRecovery = Modules.DirectoryCleanupRecovery.create({
7538
+ getProjectId: getCurrentProjectId, getAccountScopeId: () => cachedAccountScopeId,
7539
+ getRouteGeneration: () => spaRouteGeneration, findRunRecord, refresh: refreshRunCardControls,
7540
+ onOmitted: (runId, code) => appendRunRecordEvent(runId, { kind: 'technical', status: 'warning',
7541
+ title: tx('Some folder observations exceeded the storage limit; existing observations were kept.',
7542
+ '部分文件夹观测记录超过存储上限,已有记录已保留。'), detail: { code } })
7543
+ }));
7544
+ }
6989
7545
  async function stageWritebackRecovery(operations, target, requireReviewing) {
6990
- const record = findRunRecord(target.recordId, target.sessionId);
6991
- if (!record) throw new Error('The writeback run is no longer available.');
7546
+ const owner = recoveryOwner(target), generation = spaRouteGeneration, view = currentRunView;
7547
+ const assertCurrent = () => {
7548
+ const record = findRunRecord(target.recordId, target.sessionId);
7549
+ if (owner.projectId !== getCurrentProjectId() || owner.accountScopeId !== cachedAccountScopeId
7550
+ || generation !== spaRouteGeneration || view !== currentRunView || !view
7551
+ || view.recordId !== owner.runId || view.sessionId !== owner.sessionId
7552
+ || (target.runAccountScopeId && target.runAccountScopeId !== owner.accountScopeId)
7553
+ || !record || record.forkSnapshot || record.runProjectId !== owner.projectId) {
7554
+ throw new Error('The project, account or writeback run changed before dispatch.');
7555
+ }
7556
+ return record;
7557
+ };
7558
+ const record = assertCurrent();
6992
7559
  const creates = operations.filter(op => op.type === 'create' && typeof op.content === 'string');
6993
- record.retryRequireReviewing = requireReviewing === true; // In-memory; seeds an edit-only retry intent.
7560
+ record.retryRequireReviewing = requireReviewing === true;
6994
7561
  if (!creates.length) return;
6995
- const candidate = Modules.WritebackIntent.normalize({ ...recoveryOwner(target), id: crypto.randomUUID(),
7562
+ const candidate = Modules.WritebackIntent.normalize({ ...owner, id: crypto.randomUUID(),
6996
7563
  operations: creates, baseFiles: [], requireReviewing });
6997
7564
  const safe = Modules.SessionState.pickWritebackRecovery({ ...record, retryWriteback: candidate }).retryWriteback;
6998
7565
  if (safe) record.retryWriteback = safe; else delete record.retryWriteback;
6999
7566
  await flushQueuedSaveState();
7567
+ assertCurrent();
7000
7568
  }
7001
7569
 
7002
- async function settleWritebackRecovery(applied, project, verification, target) {
7570
+ async function settleWritebackRecovery(applied, project, verification, target, requireReviewing) {
7003
7571
  const record = findRunRecord(target.recordId, target.sessionId);
7004
7572
  if (!record) return;
7573
+ // retryRequireReviewing is in-memory only; a renormalized run copy loses it.
7574
+ const retryReviewing = typeof requireReviewing === 'boolean' ? requireReviewing
7575
+ : typeof record.retryRequireReviewing === 'boolean' ? record.retryRequireReviewing
7576
+ : record.executionSnapshot?.requireReviewing === true;
7005
7577
  const owner = recoveryOwner(target);
7006
7578
  const skippedPaths = new Set((applied.skipped || []).map(entry => entry.operation?.path));
7007
7579
  const creates = (record.retryWriteback?.operations || []).filter(op => skippedPaths.has(op.path));
7008
7580
  // Untouched edits (never opened / rejected) and deletes whose server check failed are safe to replay.
7009
7581
  const edits = (applied.skipped || []).filter(({ operation: op, result }) => result?.failure?.changedDocument === false && ((op?.type === 'edit'
7010
7582
  && (result.failure.stage === 'navigation' || result.failure.code === 'write_operation_failed')) || (op?.type === 'delete' && result.failure.code === 'source_zip_unavailable'))).map(entry => entry.operation);
7011
- const base = { ...(record.retryWriteback || { ...owner, id: crypto.randomUUID(), requireReviewing: record.retryRequireReviewing === true }),
7583
+ const base = { ...(record.retryWriteback || { ...owner, id: crypto.randomUUID() }), requireReviewing: retryReviewing,
7012
7584
  baseFiles: project?.files || [], requestId: applied.receiptUnconfirmed ? applied.requestId || '' : '' };
7013
7585
  // An oversized edit pre-image must not cost the create retries (the intent has a 300 KiB ceiling).
7014
7586
  const safeRetry = [[...creates, ...edits], creates].filter(ops => ops.length).map(ops => Modules.SessionState
@@ -7040,32 +7612,43 @@
7040
7612
  || owner.accountScopeId !== cachedAccountScopeId || owner.sessionId !== session.id
7041
7613
  || owner.runId !== record.id || record.undoStatus === 'applied' || record.trackedChangeStatus === 'rejected') return;
7042
7614
  const originalStatus = record.status;
7615
+ const retryAutoRecompile = typeof record.executionSnapshot?.autoRecompile === 'boolean'
7616
+ ? record.executionSnapshot.autoRecompile : undefined;
7043
7617
  // State is renormalized during the awaits below (probes, session sync), which
7044
7618
  // replaces run objects; always write through the current copy.
7045
7619
  const live = () => findRunRecord(runId, session.id) || record;
7046
7620
  let finished = false;
7047
7621
  runCancellationRequested = false;
7048
7622
  runCancellationController = new AbortController();
7049
- record.status = 'running';
7050
- record.retryingWriteback = true;
7051
- renderRunHistory({ preserveScroll: true });
7052
- const root = panel.querySelector(`[data-run-id="${cssEscape(runId)}"]`);
7053
- currentRunView = { root, recordId: runId, sessionId: session.id, runProjectId: owner.projectId,
7623
+ const generation = spaRouteGeneration;
7624
+ const requestId = record.nativeRequestId || `writeback:${owner.sessionId}:${runId}`;
7625
+ const ownerToken = crypto.randomUUID();
7626
+ const retryView = { recordId: runId, sessionId: session.id, runProjectId: owner.projectId,
7054
7627
  runAccountScopeId: owner.accountScopeId, executionSnapshot: record.executionSnapshot,
7055
- startedAt: Date.now(), writebackRetry: true, events: root?.querySelector('[data-run-events]'),
7056
- report: root?.querySelector('[data-run-report]'), status: root?.querySelector('[data-run-status]'),
7057
- runProcess: root?.querySelector('[data-run-process]'), processLabel: root?.querySelector('[data-run-process-summary]') };
7628
+ startedAt: Date.now(), writebackRetry: true };
7629
+ currentRunView = retryView;
7058
7630
  setRunning(true);
7059
7631
  const assertOwner = () => {
7060
- if (runCancellationRequested || currentRunView?.recordId !== runId) {
7632
+ if (runCancellationRequested || currentRunView !== retryView) {
7061
7633
  throw Object.assign(new Error('Save checking was cancelled.'), { code: 'codex_cancelled' });
7062
7634
  }
7063
- if (owner.projectId !== getCurrentProjectId() || owner.accountScopeId !== cachedAccountScopeId) {
7635
+ if (generation !== spaRouteGeneration || owner.projectId !== getCurrentProjectId() || owner.accountScopeId !== cachedAccountScopeId) {
7064
7636
  throw Object.assign(new Error('The project or account changed.'), { code: 'aborted_project_changed' });
7065
7637
  }
7066
7638
  };
7067
7639
  try {
7640
+ const bound = await activeTurnControl.bindBrowserRun({ requestId, projectKey: owner.projectId,
7641
+ clientRunId: runId, sessionId: session.id, ownerToken });
7068
7642
  assertOwner();
7643
+ if (!bound) throw new Error(tx('Could not start sync. Reload the page and try again.', '无法开始同步,请刷新页面后重试。'));
7644
+ // Publish running only after another page can observe this browser owner.
7645
+ live().status = 'running';
7646
+ live().retryingWriteback = true;
7647
+ renderRunHistory({ preserveScroll: true });
7648
+ const root = panel.querySelector(`[data-run-id="${cssEscape(runId)}"]`);
7649
+ Object.assign(retryView, { root, events: root?.querySelector('[data-run-events]'),
7650
+ report: root?.querySelector('[data-run-report]'), status: root?.querySelector('[data-run-status]'),
7651
+ runProcess: root?.querySelector('[data-run-process]'), processLabel: root?.querySelector('[data-run-process-summary]') });
7069
7652
  if (intent?.requestId) {
7070
7653
  const receipt = await callPageBridge('getWritebackReceipt', { requestId: intent.requestId, runProjectId: owner.projectId });
7071
7654
  if (receipt?.state === 'running') throw new Error(tx('The previous upload is still finishing. Try again shortly.', '上一轮上传仍在收尾,请稍后再试。'));
@@ -7074,8 +7657,11 @@
7074
7657
  // Edits replay against the run's original base files, so the stale guard still refuses changed documents.
7075
7658
  const result = await applySyncChangesToOverleaf(intent.operations.map(op => op.type === 'edit'
7076
7659
  ? { type: 'write', path: op.path, patches: op.patches, content: op.replaceAll } : op.type === 'delete' ? { type: 'delete', path: op.path } : { ...op, type: 'create' }),
7077
- { id: owner.projectId, files: intent.baseFiles }, { mode: 'auto', requireReviewing: intent.requireReviewing,
7078
- retryCreates: true, assistantMessage: tx('Retried the remaining file transfers for this task.', '已重试本轮未完成的文件同步。') });
7660
+ // The submitted snapshot owns the write mode; older intents may carry a lost value.
7661
+ { id: owner.projectId, files: intent.baseFiles }, { mode: 'auto', requireReviewing: typeof record.executionSnapshot?.requireReviewing === 'boolean'
7662
+ ? record.executionSnapshot.requireReviewing : intent.requireReviewing,
7663
+ autoRecompile: retryAutoRecompile,
7664
+ retryCreates: true, assistantMessageKey: 'retry_sync', assistantMessage: tx('Retried the remaining file transfers for this task.', '已重试本轮未完成的文件同步。') });
7079
7665
  assertOwner();
7080
7666
  if (result.settlement?.facts) live().settlement = WritebackSettlement.mergeRetrySettlement(live().settlement, result.settlement.facts); // Keep files outside the retry.
7081
7667
  await finishRunView(result.hasSkippedOperations ? tx('Sync incomplete', '同步未完成') : tx('Synced', '已同步'),
@@ -7100,11 +7686,19 @@
7100
7686
  } finally {
7101
7687
  // Only finishRunView settles a new terminal status; every other exit
7102
7688
  // returns the card to the status it had before the retry.
7689
+ if (currentRunView !== retryView) {
7690
+ activeTurnControl.release(requestId, ownerToken);
7691
+ return;
7692
+ }
7103
7693
  for (const copy of new Set([record, live()])) {
7104
7694
  delete copy.retryingWriteback;
7105
7695
  if (!finished && copy.status === 'running') copy.status = originalStatus;
7106
7696
  }
7107
- if (currentRunView?.recordId === runId) currentRunView = null;
7697
+ saveStateSoon();
7698
+ await flushQueuedSaveState().catch(() => {});
7699
+ activeTurnControl.release(requestId, ownerToken);
7700
+ if (currentRunView !== retryView) return;
7701
+ currentRunView = null;
7108
7702
  runCancellationRequested = false;
7109
7703
  runCancellationController = null;
7110
7704
  setRunning(false);
@@ -7195,7 +7789,9 @@
7195
7789
  });
7196
7790
  }
7197
7791
 
7198
- function recordUndoFromApply(project, applyResult) {
7792
+ async function recordUndoFromApply(project, applyResult) {
7793
+ const directoryRecovery = Modules.DirectoryCleanupRecovery ? getDirectoryCleanupRecovery() : null;
7794
+ directoryRecovery?.capture(currentRunView, applyResult);
7199
7795
  const appliedEntries = getAppliedEntries(applyResult).filter(entry => entry.result?.idempotent !== true);
7200
7796
  if (!currentRunView?.recordId || !appliedEntries.length) {
7201
7797
  return;
@@ -7210,11 +7806,18 @@
7210
7806
  return;
7211
7807
  }
7212
7808
 
7213
- const combinedAppliedOperations = [
7809
+ let combinedAppliedOperations = [
7214
7810
  ...(Array.isArray(record.appliedOperations) ? record.appliedOperations : []),
7215
7811
  ...appliedOperations
7216
7812
  ];
7813
+ const checkpoint = buildUndoCheckpoint(project, combinedAppliedOperations);
7814
+ combinedAppliedOperations = checkpoint.appliedOperations;
7217
7815
  record.appliedOperations = combinedAppliedOperations;
7816
+ if (checkpoint.undoUnavailable.length) {
7817
+ appendRunEvent({ title: tr('failureReason_snapshot_undo_unavailable_user'), status: 'warning',
7818
+ detail: checkpoint.undoUnavailable.map(item => ({ [tr('detailFile')]: item.path,
7819
+ [tr('detailReason')]: item.code, [tr('detailNext')]: tr('failureReason_snapshot_undo_unavailable_next') })) });
7820
+ }
7218
7821
  const createdCheckpoint = buildCreatedFileUndoCheckpoint(project, combinedAppliedOperations, record);
7219
7822
 
7220
7823
  if ((currentRunView?.executionSnapshot?.requireReviewing ?? state.requireReviewing) === true) {
@@ -7231,6 +7834,7 @@
7231
7834
  combinedTrackedChanges,
7232
7835
  record.undoExpectedFiles
7233
7836
  );
7837
+ record.undoOperations = Modules.UndoOperations.getFileUndoOperations(record);
7234
7838
  record.undoStatus = '';
7235
7839
  record.partialWriteback = skippedEntries.length > 0;
7236
7840
  // Recording tracked-change refs enters the run into the tracked-change
@@ -7276,7 +7880,6 @@
7276
7880
  return;
7277
7881
  }
7278
7882
 
7279
- const checkpoint = buildUndoCheckpoint(project, combinedAppliedOperations);
7280
7883
  const createdPaths = new Set(createdCheckpoint.undoOperations.map(operation => operation.path));
7281
7884
  checkpoint.undoOperations = [
7282
7885
  ...checkpoint.undoOperations.filter(operation => !createdPaths.has(operation.path)),
@@ -7286,10 +7889,7 @@
7286
7889
  ...checkpoint.undoBaseFiles.filter(file => !createdPaths.has(file.path)),
7287
7890
  ...createdCheckpoint.undoBaseFiles
7288
7891
  ];
7289
- if (!checkpoint.undoOperations.length) {
7290
- return;
7291
- }
7292
-
7892
+ if (!checkpoint.undoOperations.length && !checkpoint.undoUnavailable.length) return;
7293
7893
  record.undoOperations = checkpoint.undoOperations;
7294
7894
  record.undoBaseFiles = checkpoint.undoBaseFiles;
7295
7895
  record.undoTrackedChanges = [];
@@ -7302,6 +7902,7 @@
7302
7902
  record.undoStatus = '';
7303
7903
  record.partialWriteback = skippedEntries.length > 0;
7304
7904
  refreshRunCardControls(record.id);
7905
+ if (!checkpoint.undoOperations.length) return;
7305
7906
  appendRunEvent({
7306
7907
  title: tr('undoCheckpointPlain', { count: record.undoOperations.length }),
7307
7908
  status: 'completed',
@@ -7363,6 +7964,8 @@
7363
7964
  activity: Modules.RunActivityModel.capture(event, sanitizeAssistantVisibleText),
7364
7965
  guidanceId: sanitizeAssistantVisibleText(event.guidanceId)
7365
7966
  };
7967
+ if (event.failure) normalized.failure = sanitizeAssistantVisibleValue(event.failure);
7968
+ if (event.detailStructured) normalized.detailStructured = sanitizeAssistantVisibleValue(event.detailStructured);
7366
7969
  record.events = [...(record.events || []), normalized].slice(-MAX_RUN_EVENTS);
7367
7970
  saveStateSoon();
7368
7971
 
@@ -7387,21 +7990,8 @@
7387
7990
  refreshRunCardControls(runId);
7388
7991
  }
7389
7992
 
7390
- function applyLegacyUndoSettlement(runId, undoStatus, result) {
7391
- const run = findRunRecord(runId);
7392
- if (!run) return;
7393
- const checkpoint = run.undoOperations?.some(operation => operation?.undoCreatedFile?.v === 1)
7394
- ? advanceRunUndoProgress(run, result) : run;
7395
- Object.assign(run, WritebackSettlement.applySettlementTransition(
7396
- checkpoint,
7397
- WritebackSettlement.settleLegacyUndo({
7398
- run: checkpoint,
7399
- status: undoStatus,
7400
- result
7401
- })
7402
- ));
7403
- saveStateSoon();
7404
- refreshRunCardControls(runId);
7993
+ async function applyLegacyUndoSettlement(runId, undoStatus, result) {
7994
+ return getRunReviewController().applyLegacyUndoSettlement(runId, undoStatus, result);
7405
7995
  }
7406
7996
 
7407
7997
  function refreshRunCardControls(runId) {
@@ -7825,8 +8415,14 @@
7825
8415
  return;
7826
8416
  }
7827
8417
 
8418
+ const groupKey = String(options.groupKey || '');
8419
+ const grouped = groupKey && Array.from(region.children).find(item => item.dataset.groupKey === groupKey);
8420
+ if (grouped) {
8421
+ updateToastGroup(grouped);
8422
+ return;
8423
+ }
7828
8424
  const last = region.lastElementChild;
7829
- if (last?.classList?.contains('codex-toast') && last.dataset.baseText === value) {
8425
+ if (!groupKey && !last?.dataset.groupKey && last?.classList?.contains('codex-toast') && last.dataset.baseText === value) {
7830
8426
  const repeatCount = Number(last.dataset.repeatCount || '1') + 1;
7831
8427
  last.dataset.repeatCount = String(repeatCount);
7832
8428
  const textNode = last.querySelector('[data-toast-text]');
@@ -7844,15 +8440,34 @@
7844
8440
 
7845
8441
  const body = document.createElement('div');
7846
8442
  body.className = 'codex-toast-body';
7847
- body.dataset.toastText = 'true';
7848
- body.textContent = value;
8443
+ if (options.title) {
8444
+ const title = document.createElement('div');
8445
+ title.className = 'codex-toast-title';
8446
+ title.textContent = options.title;
8447
+ body.append(title);
8448
+ }
8449
+ const textNode = document.createElement('div');
8450
+ textNode.dataset.toastText = 'true';
8451
+ textNode.textContent = value;
8452
+ body.append(textNode);
8453
+ if (groupKey) {
8454
+ item.dataset.groupKey = groupKey;
8455
+ const details = document.createElement('details');
8456
+ details.className = 'codex-toast-group';
8457
+ const summary = document.createElement('summary');
8458
+ const list = document.createElement('ul');
8459
+ list.dataset.toastGroupItems = 'true';
8460
+ details.append(summary, list);
8461
+ body.append(details);
8462
+ }
7849
8463
  item.append(body);
8464
+ if (groupKey) updateToastGroup(item);
7850
8465
 
7851
8466
  const close = document.createElement('button');
7852
8467
  close.type = 'button';
7853
8468
  close.className = 'codex-toast-close';
7854
8469
  close.setAttribute('aria-label', tr('dismissNotification'));
7855
- close.textContent = '×';
8470
+ close.innerHTML = Modules.PanelIcons.svg('close', 'codex-close-glyph');
7856
8471
  close.addEventListener('click', () => item.remove());
7857
8472
  item.append(close);
7858
8473
 
@@ -7866,6 +8481,20 @@
7866
8481
  item.remove();
7867
8482
  }, 5200);
7868
8483
  }
8484
+
8485
+ function updateToastGroup(toast) {
8486
+ const list = toast.querySelector('[data-toast-group-items]');
8487
+ const value = String(options.groupItem || '').trim();
8488
+ if (value && !Array.from(list.children).some(item => item.textContent === value)) {
8489
+ const item = document.createElement('li');
8490
+ const code = document.createElement('code');
8491
+ code.textContent = value;
8492
+ item.append(code);
8493
+ list.append(item);
8494
+ }
8495
+ toast.querySelector('.codex-toast-group summary').textContent =
8496
+ options.groupSummary?.(list.children.length) || String(list.children.length);
8497
+ }
7869
8498
  }
7870
8499
 
7871
8500
  function updateProbeNotice(text) {