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
@@ -0,0 +1,532 @@
1
+ (function (root, factory) {
2
+ if (typeof module === 'object' && module.exports) module.exports = factory(require('../shared/failureReasons'), require('../shared/i18n'), require('../shared/agentTranscript'));
3
+ else root.CodexOverleafModuleRegistry.define('RunCompletionMeta', ['FailureReasons', 'I18n', 'AgentTranscript'], factory);
4
+ })(typeof window !== 'undefined' ? window : globalThis, function (FailureReasons, I18n, AgentTranscript) {
5
+ 'use strict';
6
+
7
+ // Read only persisted product evidence; never infer a launch outcome from
8
+ // arbitrary answer text or change the run's archived status.
9
+ function projectNotStartedRun(run, locale = 'en') {
10
+ const event = (Array.isArray(run?.events) ? run.events : []).filter(item => item?.kind === 'report').at(-1);
11
+ const source = event?.detailStructured?.conclusionSource;
12
+ if (source?.origin !== 'product' || source.key !== 'launch_not_started'
13
+ || !['en', 'zh'].some(language => AgentTranscript.projectProductConclusion(source, language)
14
+ === event.detailStructured.conclusion)) return null;
15
+ const meta = Array.isArray(event.detailStructured.meta) ? event.detailStructured.meta : [];
16
+ const savedNext = meta.find(row => row?.key === 'nextStep')?.value || '';
17
+ const declinedChoice = event.failure?.evidence?.declinedChoice;
18
+ const cancelled = declinedChoice === true || run.status === 'cancelled'
19
+ // Legacy reports predate the structured marker; only those need the copy fallback.
20
+ || (declinedChoice === undefined && ['en', 'zh'].some(language =>
21
+ savedNext === I18n.t(language, 'failureReason_reviewing_unavailable_cancelled_next')));
22
+ const unavailable = event.failure?.code === 'reviewing_unavailable' || cancelled;
23
+ const tx = (en, zh) => locale === 'zh' ? zh : en;
24
+ const next = localizeMetaRow({ key: 'nextStep', value: savedNext }, {
25
+ tx, getLocale: () => locale, tr: key => I18n.t(locale, key)
26
+ });
27
+ return {
28
+ cancelled,
29
+ status: I18n.t(locale, cancelled ? 'runNotStartedCancelled' : 'runNotStarted'),
30
+ title: I18n.t(locale, unavailable ? 'runNotStartedTrackTitle' : 'runNotStartedModeTitle'),
31
+ body: I18n.t(locale, unavailable ? 'runNotStartedTrackBody' : 'runNotStartedModeBody'),
32
+ nextStep: cancelled ? I18n.t(locale, 'runNotStartedCancelledNext')
33
+ : next.value || I18n.t(locale, 'runNotStartedNext')
34
+ };
35
+ }
36
+
37
+ function projectCompletionConclusion(event, run, locale = 'en', options = {}) {
38
+ const structured = event?.detailStructured;
39
+ const raw = options.flat ? options.text : String(structured?.conclusion || '');
40
+ if (structured?.conclusionSource) {
41
+ if (structured.conclusionSource.key !== 'writeback'
42
+ && !['en', 'zh'].some(language => AgentTranscript.projectProductConclusion(structured.conclusionSource, language) === raw)) return raw;
43
+ return AgentTranscript.projectProductConclusion(structured.conclusionSource, locale, options.localizeCompileReason, raw) ?? raw;
44
+ }
45
+ // Reload recovery predates structured conclusions but retains an explicit
46
+ // product source. Translate only its intact flat report, never lookalikes.
47
+ if (options.flat && !structured && event?.kind === 'report' && event.status === 'failed'
48
+ && !event.streamRole && !event.guidanceId && !event.subagent
49
+ && event.failure?.code === 'native_request_failed' && event.failure.source === 'panel_reload'
50
+ && raw === event.detail && ['en', 'zh'].some(language =>
51
+ event.title === I18n.t(language, 'restoredRunStoppedTitle')
52
+ && raw === I18n.t(language, 'restoredRunStoppedDetail'))) {
53
+ return I18n.t(locale, 'restoredRunStoppedDetail');
54
+ }
55
+ // Legacy cancellation has a unique adjacent producer-owned lifecycle event.
56
+ // Text alone, status alone, and a user's/model's matching answer confer no identity.
57
+ const events = Array.isArray(run?.events) ? run.events : [];
58
+ const matches = events.map((item, index) => ({ item, index })).filter(({ item }) => item.kind === 'report'
59
+ && (structured ? item.detailStructured === structured : item.detail === event.detail));
60
+ const previous = matches.length === 1 ? events[matches[0].index - 1] : null;
61
+ if (run?.status !== 'cancelled' || event.kind !== 'report' || event.status !== 'completed'
62
+ || !['Task report', '本轮完成报告'].includes(event.title)
63
+ || previous?.activity?.kind !== 'lifecycle' || previous.activity.state !== 'cancelled'
64
+ || previous.activity.target !== 'cancelled' || previous.streamRole || previous.guidanceId
65
+ || !['Current Codex task was cancelled.', '已中断当前 Codex 任务。'].includes(previous.title)) return raw;
66
+ const source = { origin: 'product', key: 'cancelled' };
67
+ const en = AgentTranscript.projectProductConclusion(source, 'en'), zh = AgentTranscript.projectProductConclusion(source, 'zh');
68
+ const known = options.flat ? [`Conclusion: ${en}`, `结论:${zh}`] : [en, zh];
69
+ if (!known.includes(raw)) return raw;
70
+ const projected = AgentTranscript.projectProductConclusion(source, locale);
71
+ return options.flat ? (locale === 'zh' ? `结论:${projected}` : `Conclusion: ${projected}`) : projected;
72
+ }
73
+
74
+ // Like projectNotStartedRun, this is a view of intact product reports, never
75
+ // a new terminal state. Counts remain in storage (retry replacement reads them).
76
+ function projectFinishedRun(run, locale = 'en', { projectRunSettlement } = {}) {
77
+ if (!run || run.status === 'running' || run.retryingWriteback || projectNotStartedRun(run, locale)) return null;
78
+ const event = (Array.isArray(run.events) ? run.events : []).filter(item => item?.kind === 'report').at(-1);
79
+ const structured = event?.detailStructured, source = structured?.conclusionSource;
80
+ if (typeof structured?.conclusion !== 'string' || source?.origin !== 'product' || !['en', 'zh'].some(language =>
81
+ AgentTranscript.projectProductConclusion(source, language, undefined, structured.conclusion) === structured.conclusion)) return null;
82
+ const t = (key, values) => I18n.t(locale, key, values);
83
+ const tx = (en, zh) => locale === 'zh' ? zh : en;
84
+ const meta = Array.isArray(structured.meta) ? structured.meta : [];
85
+ const value = key => meta.find(row => row?.key === key)?.value || '';
86
+ const counts = /^(?:wrote (\d+) items?, skipped (\d+) items?|已写入 (\d+) 项,跳过 (\d+) 项)$/.exec(value('writeResult'));
87
+ const wrote = counts ? Number(counts[1] ?? counts[3]) : null;
88
+ const skipped = counts ? Number(counts[2] ?? counts[4]) : null;
89
+ const projection = projectRunSettlement?.(run) || {};
90
+ const facts = run.settlement || run.settlementFacts || {};
91
+ const failure = projection.primaryFailure || event.failure;
92
+ const files = Array.isArray(facts.fileSettlements) ? facts.fileSettlements.filter(item => item && typeof item.path === 'string') : [];
93
+ const reloadInterrupted = source.key === 'reload_interrupted';
94
+ const payload = run.recoveryPayload || run;
95
+ const hasWriteEvidence = wrote > 0 || files.length > 0 || Boolean(run.saveCheck)
96
+ || ['appliedOperations', 'undoOperations', 'undoTrackedChanges'].some(key => payload[key]?.length)
97
+ || (reloadInterrupted && run.recoveryRetention?.state === 'omitted')
98
+ || ![undefined, 'none'].includes(facts.evidence?.applied)
99
+ || ![undefined, 'not-attempted'].includes(facts.evidence?.readBack)
100
+ || projection.changedDocument === true || ['changed', 'possibly-changed'].includes(facts.documentEffect);
101
+ const unconfirmed = projection.requiresWriteReview === true
102
+ || FailureReasons.isUnconfirmedWriteFailure(failure)
103
+ || facts.documentEffect === 'possibly-changed' || facts.evidence?.readBack === 'mismatch'
104
+ || (reloadInterrupted && hasWriteEvidence);
105
+ const noWrites = wrote === 0 && skipped === 0 && !unconfirmed && !hasWriteEvidence;
106
+ const display = FailureReasons.formatFailureReasonForDisplay(failure, locale, key => {
107
+ const translated = t(key);
108
+ return translated !== key ? translated : undefined;
109
+ });
110
+ const file = typeof failure?.file === 'string' ? failure.file : '';
111
+ const shortKey = `runOutcomeCause_${failure?.source === 'panel_reload' ? 'panel_reload_interrupted' : failure?.code}`;
112
+ const detectorIds = Array.isArray(failure?.evidence?.detectorIds) ? [...new Set(failure.evidence.detectorIds)] : [];
113
+ const detectorId = detectorIds.length === 1 ? detectorIds[0] : '';
114
+ const findingKey = typeof detectorId === 'string' && /^[a-z]+(?:-[a-z]+)+$/.test(detectorId)
115
+ ? `sensitiveFinding_${detectorId.replace(/-/g, '_')}` : '';
116
+ const label = t(I18n.MESSAGES.en[findingKey] ? findingKey : 'sensitiveFinding_mixed');
117
+ // Chinese copy separates a Latin term from the preceding Han text with a space.
118
+ const finding = locale === 'zh' && /^[A-Za-z]/.test(label) ? ` ${label}` : label;
119
+ const shortTitle = I18n.MESSAGES[locale]?.[shortKey] ? t(shortKey, { file, finding }) : '';
120
+ const savedNext = localizeMetaRow({ key: 'nextStep', value: value('nextStep') }, {
121
+ tx, getLocale: () => locale, tr: t
122
+ }).value;
123
+ const base = { failure, file, wrote, skipped, noWrites, unconfirmed,
124
+ nextStep: display?.nextAction || savedNext || t('runOutcomeNext'), files: [] };
125
+ if (unconfirmed) return { ...base, kind: 'check-overleaf', tone: 'error',
126
+ status: t('runOutcomeCheck'), title: ['undo', 'accept'].includes(failure?.stage)
127
+ ? shortTitle || t('runOutcomeCheck') : t(file ? 'runOutcomeUnconfirmedFile' : 'runOutcomeUnconfirmed', { file }),
128
+ body: ['undo', 'accept'].includes(failure?.stage) ? display?.userMessage || t('runOutcomeUnconfirmedBody') : t('runOutcomeUnconfirmedBody'),
129
+ nextStep: file && !['undo', 'accept'].includes(failure?.stage) ? t('runOutcomeCheckNext', { file }) : base.nextStep };
130
+ if (reloadInterrupted) return { ...base, kind: 'didnt-finish', tone: 'warning',
131
+ status: t('runOutcomeIncomplete'), title: t('runOutcomeCause_panel_reload_interrupted'),
132
+ body: t(run.mode === 'ask' ? 'runOutcomeNoWrites' : 'runOutcomeReloadUnknownBody') };
133
+ if (wrote > 0 && skipped > 0) {
134
+ const failures = Array.isArray(facts.failures) ? facts.failures : [];
135
+ const entries = files.flatMap(item => {
136
+ const result = [];
137
+ if (['complete', 'partial'].includes(item.applied)) result.push({ file: item.path, written: true });
138
+ if (['failed', 'partial'].includes(item.applied)) {
139
+ const cause = failures.find(candidate => candidate?.file === item.path)
140
+ || { code: item.failureCodes?.[0], file: item.path };
141
+ const reason = cause.code === 'stale_source_changed' ? t('runOutcomeStaleReason')
142
+ : FailureReasons.formatFailureReasonForDisplay(cause, locale, t)?.userMessage || t('runOutcomeSkipped');
143
+ result.push({ file: item.path, written: false, reason });
144
+ }
145
+ return result;
146
+ });
147
+ return { ...base, kind: 'partly-written', tone: 'warning', status: t('runOutcomePartial'),
148
+ title: t('runOutcomePartialTitle', { wrote, total: wrote + skipped }), body: '',
149
+ files: entries.sort((a, b) => Number(b.written) - Number(a.written)),
150
+ nextStep: t('runOutcomePartialNext') };
151
+ }
152
+ if (wrote === 0 && skipped > 0 && facts.documentEffect === 'unchanged' && failure) {
153
+ return { ...base, kind: 'not-written', tone: 'warning', status: t('runOutcomeNotWritten'),
154
+ title: shortTitle || t('runOutcomeNotWritten'),
155
+ body: shortTitle ? t('runOutcomeNotWrittenBody') : display?.userMessage || t('runOutcomeNotWrittenBody'),
156
+ nextStep: failure.code === 'stale_source_changed' ? t('runOutcomeStaleNext') : base.nextStep };
157
+ }
158
+ if (noWrites && ['cancelled', 'rejected'].includes(run.status)) return { ...base, kind: 'cancelled', tone: 'neutral' };
159
+ if (noWrites && (event.status === 'failed' || run.status === 'failed') && failure) {
160
+ return { ...base, kind: 'didnt-finish', tone: 'warning', status: t('runOutcomeIncomplete'),
161
+ title: shortTitle || t('runOutcomeIncomplete'), body: shortTitle ? t('runOutcomeNoWrites') : display?.userMessage || t('runOutcomeNoWrites'),
162
+ nextStep: failure.code === 'codex_context_limit' ? t('runOutcomeContextNext')
163
+ : failure.code === 'native_request_failed' && failure.source !== 'panel_reload' ? t('runOutcomeNativeNext') : base.nextStep };
164
+ }
165
+ if (source.key === 'writeback' && source.outcome === 'synced' && source.hasCompile && source.errorCount > 0
166
+ && !['failed', 'cancelled', 'rejected'].includes(run.status)) {
167
+ // Verify the whole original suffix above before projecting just the prefix
168
+ // and sync result. Never strip a model sentence by matching its wording.
169
+ const prefix = structured.conclusion.slice(0, source.prefixLength);
170
+ const raw = prefix || AgentTranscript.projectProductConclusion({ origin: 'product', key: 'synced' }, locale);
171
+ const conclusion = prefix ? (source.prefixKey
172
+ ? AgentTranscript.projectProductConclusion({ origin: 'product', key: source.prefixKey }, locale) : prefix) : raw;
173
+ return { ...base, kind: 'compile-errors', tone: 'warning', conclusion,
174
+ compileCount: source.errorCount, compileErrors: (Array.isArray(source.errors) ? source.errors : []).slice(0, 3),
175
+ compileChip: t('runOutcomeCompileChip', { count: source.errorCount, plural: source.errorCount === 1 ? '' : 's' }),
176
+ title: t('runOutcomeCompileTitle', { count: source.errorCount, plural: source.errorCount === 1 ? '' : 's' }) };
177
+ }
178
+ return null;
179
+ }
180
+
181
+ const FLAT_REPORT_STATUS_SECTIONS = [
182
+ { key: 'saveState', prefixes: ['Save:', '保存:'], en: 'Save', zh: '保存' },
183
+ { key: 'unchangedReason', prefixes: ['Why nothing changed:', '未修改原因:'], en: 'Why nothing changed', zh: '未修改原因' },
184
+ { key: 'writeResult', prefixes: ['Write result:', '写入结果:'], en: 'Write result', zh: '写入结果' },
185
+ { key: 'undo', prefixes: ['Undo:', '可撤销:'], en: 'Undo', zh: '可撤销' },
186
+ { key: 'nextStep', prefixes: ['Next:', '下一步:'], en: 'Next', zh: '下一步' }
187
+ ];
188
+
189
+ // Reports persist the language used when the run finished. Re-project only
190
+ // product-owned metadata; model conclusions and custom reasons stay intact.
191
+ const META_COPY = {
192
+ unchangedReason: [
193
+ ['This run was Ask mode.', '这轮是只问不改。'],
194
+ ['You cancelled this change.', '你取消了这轮修改。'],
195
+ ['No file changes need to sync back to Overleaf.', '没有产生需要同步回 Overleaf 的文件改动。'],
196
+ ['No approved file changes remain to sync back to Overleaf.', '没有剩余已确认的文件改动需要同步回 Overleaf。'],
197
+ ['Sensitive content was found before sending.', '发送前发现了敏感内容。']
198
+ ],
199
+ undo: [['this run has no reversible writes', '本轮没有可撤销的写入']],
200
+ saveState: [['Saved', '已确认保存'],
201
+ ['Pending confirmation; check save status', '待确认,可检查保存状态']],
202
+ nextStep: [
203
+ ['Review the skipped items in this report, then retry after resolving them.', '请查看本轮报告中的跳过项,处理后可以重试。'],
204
+ ['Nothing was written in this run. Review the skipped reasons, fix them, and retry.', '这轮没有任何内容写入。请查看跳过原因,处理后重试。'],
205
+ ['Review the skipped reasons. You can use "Undo written parts" to roll back what already reached Overleaf, then fix conflicts and retry.', '请查看跳过原因。已写入的部分可以点击“撤销已写入部分”回退,处理冲突后再重试。'],
206
+ ['Resolve the issue above, then retry.', '请处理上面的原因后重试。'],
207
+ ['You can continue asking, or add more @context and run another check.', '可以继续追问,或加入更多 @context 后再检查。'],
208
+ ['Review these tracked changes in Overleaf.', '请在 Overleaf 中查看这些文件的留痕修改。'],
209
+ ['You can continue the conversation, or adjust @context and run again.', '可以继续追问,或调整 @context 后重新运行。'],
210
+ ['The first queued follow-up will start next.', '接下来会自动启动队首的后续任务。'],
211
+ ['Edit the task and run again.', '可以修改任务后重新运行。'],
212
+ ['Continue the conversation, or switch to Auto to let Codex edit files.', '可以继续追问,或切换到自动写入后让 Codex 修改文件。'],
213
+ ['Switch to Auto only when you want Codex to edit files.', '只有希望 Codex 修改文件时,才需要切换到自动写入。'],
214
+ ['Review the synced file in Overleaf.', '请在 Overleaf 中查看同步后的文件。'],
215
+ ['Check Overleaf manually before running again. Local mirror refresh and auto compile were skipped.', '再次运行前请先手动检查 Overleaf。本地 mirror 刷新和自动编译已跳过。'],
216
+ ['Switch Overleaf to Reviewing/Track Changes manually and rerun, or turn off Track before writing.', '请在 Overleaf 手动切到 Reviewing/Track Changes 后重新运行,或关闭“留痕”再写入。'],
217
+ ['The main agent must review and adopt the withheld files before syncing them.', '主代理需核对并接管未同步文件后再写回。'],
218
+ ['Select the intended region again and request only changes within it. Valid written parts can be undone.', '请重新选择目标范围,仅请求修改该范围。已写入的合法部分可撤销。'],
219
+ ['Reload the Overleaf project or reopen the .tex file you want to process, then retry.', '请刷新 Overleaf 项目或重新打开要处理的 .tex 文件后再试。'],
220
+ ['Retry with an explicit target such as @file:main.tex and the exact section to edit; if this repeats, open Technical Details.', '请带上明确目标重试,例如 @file:main.tex 和具体要修改的小节;如果重复出现,请打开 Technical Details。'],
221
+ ['Restart Codex Overleaf or start a new run if the installed skill is not visible immediately.', '如果新安装的 skill 没有立刻出现,请重启 Codex Overleaf 或开始新一轮运行。'],
222
+ ['If files still need to be deleted, rerun and confirm deletion separately.', '如果仍需要删除文件,请重新运行并单独确认删除。'],
223
+ ['Expand the write result, review the skipped reasons, then retry after fixing them.', '请展开写入结果查看跳过原因,处理后可以重试。'],
224
+ ['Adjust the task description and run again.', '可以调整任务描述后重新运行。'],
225
+ ['Continue the conversation or run the next task.', '可以继续追问,或运行下一项任务。']
226
+ ]
227
+ };
228
+
229
+ let errorNextSteps;
230
+ const errorNextStepTemplates = [];
231
+ function localizeErrorNextStep(value, tx) {
232
+ if (!errorNextSteps) {
233
+ errorNextSteps = new Map();
234
+ const add = pair => {
235
+ if (!pair.every(text => typeof text === 'string' && text)) return;
236
+ if (pair.every(text => text.includes('{file}'))) {
237
+ for (const template of pair) {
238
+ const parts = template.split('{file}');
239
+ if (parts.length === 2) errorNextStepTemplates.push({ pair,
240
+ pattern: new RegExp('^' + parts.map(part => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('([^\\r\\n]*?)') + '$') });
241
+ }
242
+ }
243
+ for (const text of pair) if (!errorNextSteps.has(text)) errorNextSteps.set(text, pair);
244
+ };
245
+ // Reuse the producer's own bilingual copy rather than maintain another
246
+ // translation catalog. Only exact, known next-step prose is accepted.
247
+ for (const key of Object.keys(I18n?.MESSAGES?.en || {})) {
248
+ if (/^failureReason_.+_next$/.test(key)) add([I18n.MESSAGES.en[key], I18n.MESSAGES.zh[key]]);
249
+ }
250
+ const catalog = FailureReasons?.FAILURE_CODE_CATALOG || {};
251
+ for (const [code, entry] of Object.entries(catalog)) {
252
+ const key = `failureReason_${code}_next`;
253
+ const pair = [I18n?.MESSAGES?.en?.[key], I18n?.MESSAGES?.zh?.[key]];
254
+ if (entry.fallbackNextAction && pair.every(Boolean)) errorNextSteps.set(entry.fallbackNextAction, pair);
255
+ }
256
+ const legacyInputs = ['Mode must be "ask" or "auto"', 'Agent returned invalid JSON',
257
+ 'Could not parse Codex output', 'timed out', 'output limit exceeded',
258
+ 'unsupported_parameter reasoning.summary', 'quota', 'checkpoint', 'changed while Codex was working'];
259
+ if (AgentTranscript?.translateRawError) {
260
+ for (const message of [...Object.keys(catalog), ...legacyInputs]) {
261
+ add(['en', 'zh'].map(locale => AgentTranscript.translateRawError(message, { locale }).nextStep));
262
+ }
263
+ add(['en', 'zh'].map(locale => AgentTranscript.translateRawError('', { locale, codexReturned: true }).nextStep));
264
+ }
265
+ }
266
+ const pair = errorNextSteps.get(value);
267
+ if (pair) return tx(...pair);
268
+ for (const entry of errorNextStepTemplates) {
269
+ const match = entry.pattern.exec(String(value || ''));
270
+ if (match) return tx(...entry.pair).replace('{file}', () => match[1]);
271
+ }
272
+ return value;
273
+ }
274
+
275
+ function localizeMetaRow(row, options) {
276
+ if (!row || typeof row !== 'object') return row;
277
+ const entry = FLAT_REPORT_STATUS_SECTIONS.find(item => item.key === row.key);
278
+ if (!entry) return row;
279
+ const { tx } = options;
280
+ let value = row.value;
281
+ const pair = (META_COPY[row.key] || []).find(values => values.includes(value));
282
+ if (pair) value = tx(...pair);
283
+ if (row.key === 'nextStep') value = localizeErrorNextStep(value, tx);
284
+ if (row.key === 'writeResult') {
285
+ const counts = /^(?:已写入 (\d+) 项,跳过 (\d+) 项|wrote (\d+) items?, skipped (\d+) items?)$/.exec(String(value || ''));
286
+ if (counts) {
287
+ const written = Number(counts[1] ?? counts[3]), skipped = Number(counts[2] ?? counts[4]);
288
+ value = tx(`wrote ${written} item${written === 1 ? '' : 's'}, skipped ${skipped} item${skipped === 1 ? '' : 's'}`,
289
+ `已写入 ${written} 项,跳过 ${skipped} 项`);
290
+ }
291
+ }
292
+ if (row.key === 'undo') {
293
+ const count = /^(?:可撤销本轮 (\d+) 项写入|this run has (\d+) reversible writes?)$/.exec(String(value || ''));
294
+ if (count) {
295
+ const n = Number(count[1] ?? count[2]);
296
+ value = tx(`this run has ${n} reversible write${n === 1 ? '' : 's'}`, `可撤销本轮 ${n} 项写入`);
297
+ }
298
+ }
299
+ if (['nextStep', 'unchangedReason'].includes(row.key) && options.getLocale && options.tr) {
300
+ value = FailureReasons?.localizeFailureMessage?.(value, options.getLocale(), key => {
301
+ const translated = options.tr(key);
302
+ return translated === key ? undefined : translated;
303
+ }) ?? value;
304
+ }
305
+ return { ...row, label: tx(entry.en, entry.zh), value };
306
+ }
307
+
308
+ function splitFlatCompletionReport(text, tx = value => value) {
309
+ const raw = typeof text === 'string' ? text : '';
310
+ if (!raw.trim()) return { body: raw, meta: [] };
311
+ const bodySections = [], meta = [];
312
+ for (const section of raw.split(/\n{2,}/)) {
313
+ const trimmed = section.trim();
314
+ if (!trimmed) continue;
315
+ const entry = trimmed.includes('\n') ? null
316
+ : FLAT_REPORT_STATUS_SECTIONS.find(item => item.prefixes.some(prefix => trimmed.startsWith(prefix)));
317
+ if (entry) {
318
+ const prefix = entry.prefixes.find(value => trimmed.startsWith(value));
319
+ const value = trimmed.slice(prefix.length).trim();
320
+ if (value) {
321
+ meta.push({ key: entry.key, label: tx(entry.en, entry.zh), value });
322
+ continue;
323
+ }
324
+ }
325
+ bodySections.push(trimmed);
326
+ }
327
+ return { body: bodySections.join('\n\n'), meta };
328
+ }
329
+
330
+ function projectCompletionBody(body, tx = value => value) {
331
+ if (typeof body !== 'string' || !body) return body;
332
+ const sections = [['Checked', '检查范围'], ['Findings', '发现'], ['Planned changes', '计划修改'],
333
+ ['Changes', '修改'], ['Skipped', '跳过原因']];
334
+ let rest = body, previous = -1, language;
335
+ const projected = [];
336
+ while (rest) {
337
+ let found;
338
+ for (let index = previous + 1; index < sections.length && !found; index++) {
339
+ for (const locale of [0, 1]) {
340
+ const prefix = sections[index][locale] + (locale ? ':\n' : ':\n');
341
+ if ((language === undefined || locale === language) && rest.startsWith(prefix)) {
342
+ found = { index, locale, prefix }; break;
343
+ }
344
+ }
345
+ }
346
+ if (!found) return body;
347
+ language = found.locale;
348
+ previous = found.index;
349
+ rest = rest.slice(found.prefix.length);
350
+ const heading = tx(sections[previous][0] + ':', sections[previous][1] + ':');
351
+ // Skipped may contain arbitrary model/error prose. Only a complete,
352
+ // exact product-owned governance block can be re-projected.
353
+ if (previous === sections.length - 1) {
354
+ if (!/^- [^\r\n]+/.test(rest)) return body;
355
+ projected.push(heading + '\n' + projectGovernanceSkippedBody(rest, tx));
356
+ return projected.join('\n\n');
357
+ }
358
+ const boundary = rest.indexOf('\n\n');
359
+ const items = boundary < 0 ? rest : rest.slice(0, boundary);
360
+ if (!/^- [^\r\n]+(?:\n- [^\r\n]+)*$/.test(items)) return body;
361
+ projected.push(heading + '\n' + items);
362
+ if (boundary < 0) return projected.join('\n\n');
363
+ rest = rest.slice(boundary + 2);
364
+ if (!rest) return body;
365
+ }
366
+ return body;
367
+ }
368
+
369
+ function projectGovernanceSkippedBody(body, tx) {
370
+ const locale = tx('en', 'zh');
371
+ const lookup = key => I18n.t(locale, key);
372
+ const operationLabels = { edit: '编辑', create: '新建', delete: '删除', move: '移动', rename: '重命名' };
373
+ const pattern = /(^|\n)- ([^\r\n]+?)(?:: (edit|create|delete|move|rename|binary-create|overwrite-binary) was not written|:(编辑|新建|删除|移动|重命名|binary-create|overwrite-binary)没有写入)\n([ \t]*)(?:Reason|原因): ([^\r\n]+)\n\5(?:Stage|阶段): (?:unknown|preflight)\n\5(?:Code|代码): (unknown_legacy_failure|governance_blocked|governance_readonly|governance_writable_allowlist)\n\5(?:Next|下一步): ([^\r\n]+)(?=\n- |$)/g;
374
+ return body.replace(pattern, (block, prefix, file, enOperation, zhOperation, indent, message, code, next) => {
375
+ const display = FailureReasons.formatFailureReasonForDisplay({ code: 'unknown_legacy_failure', userMessage: message }, locale, lookup);
376
+ if (!['governance_blocked', 'governance_readonly', 'governance_writable_allowlist'].includes(display.code)) return block;
377
+ if (code !== 'unknown_legacy_failure' && code !== 'governance_blocked' && code !== display.code) return block;
378
+ const nextKeys = [display.code, 'unknown_legacy_failure'].flatMap(value => [
379
+ FailureReasons.FAILURE_CODE_CATALOG[value]?.fallbackNextAction,
380
+ I18n.t('en', `failureReason_${value}_next`), I18n.t('zh', `failureReason_${value}_next`)
381
+ ]);
382
+ if (!nextKeys.includes(next)) return block;
383
+ const operation = enOperation || Object.keys(operationLabels).find(key => operationLabels[key] === zhOperation) || zhOperation;
384
+ const header = tx(`${file}: ${operation} was not written`, `${file}:${operationLabels[operation] || operation}没有写入`);
385
+ const failure = { code: display.code, stage: 'preflight', userMessage: display.userMessage, nextAction: display.nextAction };
386
+ return `${prefix}- ${header}\n${AgentTranscript.formatFailureBlock(failure, locale, indent)}`;
387
+ });
388
+ }
389
+
390
+ function projectCompletionMeta(meta, run, options) {
391
+ const { tx, trackedChangeInFlight, isTrackedChangeLifecycleRun, projectRunSettlement } = options;
392
+ if (!Array.isArray(meta) || !run) return meta;
393
+ const undone = run.undoStatus === 'applied' || run.trackedChangeStatus === 'rejected';
394
+ const rows = (undone ? meta.filter(row => row?.key !== 'saveState') : meta).map(row => localizeMetaRow(row, options))
395
+ .filter(row => row?.key !== 'recoveryRetention' && row?.key !== 'directoryObservation');
396
+ const pending = run.pendingDirectoryCleanup;
397
+ if (!run.forkSnapshot && pending?.v === 1 && pending.runId === run.id
398
+ && pending.projectId === run.runProjectId && pending.projectId === options.projectId
399
+ && pending.accountScopeId && pending.accountScopeId === options.accountScopeId) {
400
+ const paths = [...new Set((Array.isArray(pending.directories) ? pending.directories : []).filter(item => item?.projectId === pending.projectId
401
+ && typeof item.path === 'string' && item.path && !/^[\\/]|^[A-Za-z]:|[\u0000-\u001f\u007f]/.test(item.path)
402
+ && !item.path.replace(/\\/g, '/').split('/').some(part => !part || part === '.' || part === '..')).map(item => item.path))];
403
+ if (paths.length) rows.push({ key: 'directoryObservation', label: tx('Folders', '文件夹'),
404
+ value: tx('The run recorded these folders at completion; their current status has not been rechecked: ',
405
+ '本轮结束时记录了以下文件夹,当前状态尚未重新核验:') + paths.join(', ') });
406
+ }
407
+ const retentionOmitted = hasOmittedRecoveryRetention(run);
408
+ const undoUnavailable = !undone && run.trackedChangeStatus !== 'accepted'
409
+ ? projectRunSettlement?.(run).undoUnavailable || [] : [];
410
+ const inFlight = trackedChangeInFlight?.get(run.id);
411
+ const tracked = isTrackedChangeLifecycleRun(run);
412
+ const pendingFileUndo = tracked && run.trackedChangeStatus === 'needs_review' && run.undoOperations?.some(operation => operation?.undoCreatedFile?.v === 1 || operation?.type === 'create');
413
+ let undo;
414
+ if (run.undoStatus === 'running' || inFlight === 'reject') {
415
+ undo = tx('Undoing changes...', '正在撤销修改…');
416
+ } else if (run.undoStatus === 'applied' || (tracked && run.trackedChangeStatus === 'rejected')) {
417
+ undo = tx('This run\'s changes have been undone.', '本轮修改已撤销。');
418
+ } else if (tracked && run.trackedChangeStatus === 'accepted') {
419
+ undo = tx('Changes accepted; undo is no longer available.', '修改已接受,无法再撤销。');
420
+ } else if (run.undoStatus === 'partial' || pendingFileUndo) {
421
+ undo = hasUndoProgress(run)
422
+ ? projectRunSettlement(run).canUndo
423
+ ? tx(pendingFileUndo ? 'Some changes were undone; retry the remaining files.' : 'Some changes were undone; remaining changes can still be undone.', pendingFileUndo ? '已撤销部分修改,可重试剩余文件。' : '已撤销部分修改,剩余修改仍可撤销。')
424
+ : tx('Some changes were undone.', '已撤销部分修改。')
425
+ : projectRunSettlement(run).canUndo
426
+ ? tx(pendingFileUndo ? 'Undo is incomplete; retry the remaining files.' : 'Undo is incomplete; remaining changes can still be undone.', pendingFileUndo ? '撤销未完成,可重试剩余文件。' : '撤销未完成,剩余修改仍可撤销。')
427
+ : tx('Undo is incomplete.', '撤销未完成。');
428
+ }
429
+ if (!undo && !run.saveCheck && !run.saveConfirmedAt && !retentionOmitted && !undoUnavailable.length) return rows;
430
+ const projected = rows.map(row => row?.key === 'undo' && undo
431
+ ? { ...row, label: tx('Undo', '撤销'), value: undo }
432
+ : row?.key === 'saveState' && (run.saveCheck || run.saveConfirmedAt)
433
+ ? { ...row, label: tx('Save', '保存'), value: run.saveCheck
434
+ ? tx('Pending confirmation', '待确认') : tx('Saved', '已确认保存') }
435
+ : row);
436
+ if (retentionOmitted) {
437
+ const budget = ['run-budget', 'session-budget'].includes(run.recoveryRetention.reason);
438
+ const directory = run.recoveryRetention.directoryCleanup === true ? tx(
439
+ 'Recorded folder observations were not retained; their current status is unknown.',
440
+ '文件夹历史观测记录未保留,当前状态未核验。') : '';
441
+ const recovery = budget
442
+ ? tx('Persistent recovery data was not retained: local recovery budget exceeded. After reload, use Overleaf history to recover.',
443
+ '持久恢复材料未保留:超过本地恢复预算,刷新后请使用 Overleaf 历史恢复。')
444
+ : tx('Persistent recovery data was not retained. After reload, use Overleaf history to recover.',
445
+ '持久恢复材料未保留,刷新后请使用 Overleaf 历史恢复。');
446
+ projected.push({ key: 'recoveryRetention', label: tx('Recovery data', '恢复材料'),
447
+ value: directory && (run.recoveryRetention.coreRetained === true || undone || run.trackedChangeStatus === 'accepted')
448
+ ? directory : [recovery, directory].filter(Boolean).join(' ') });
449
+ }
450
+ if (undoUnavailable.length) {
451
+ const warning = options.tr('failureReason_snapshot_undo_unavailable_user')
452
+ + ' ' + undoUnavailable.map(item => item.path).join(', ');
453
+ const retained = projected.find(row => row.key === 'recoveryRetention');
454
+ if (retained) { retained.value += ' ' + warning; retained.undoUnavailable = true; }
455
+ else projected.push({ key: 'recoveryRetention', label: tx('Recovery data', '恢复材料'), undoUnavailable: true, value: warning });
456
+ }
457
+ return projected;
458
+ }
459
+
460
+ function hasOmittedRecoveryRetention(run) {
461
+ return run?.recoveryRetention?.state === 'omitted' && (run.recoveryRetention.directoryCleanup === true
462
+ || (run.undoStatus !== 'applied' && !['accepted', 'rejected'].includes(run.trackedChangeStatus)));
463
+ }
464
+
465
+ function hasUndoProgress(run) {
466
+ const failures = run?.settlement?.failures || run?.settlementFacts?.failures || [];
467
+ return (Array.isArray(run?.events) ? run.events : []).some(event => {
468
+ if (!['completed', 'failed'].includes(event?.status) || event.streamRole || ['report', 'guidance', 'stream'].includes(event.kind)) return false;
469
+ const count = String(event.title || '').match(/^(?:Undo result: (?:undone|rejected) (\d+) (?:item\(s\)|file\(s\)|tracked change\(s\)), skipped \d+(?: item\(s\))?|撤销结果:已(?:撤销|拒绝) (\d+) (?:项|条留痕|个文件),跳过 \d+ (?:项|条))$/);
470
+ if (!(Number(count?.[1] || count?.[2]) > 0)) return false;
471
+ const detail = event.detail || {}, applied = detail.Undone || detail.Rejected || detail['已撤销'] || detail['已拒绝'];
472
+ const skipped = detail.Skipped || detail['跳过'], path = item => item?.File || item?.['文件'];
473
+ return Array.isArray(applied) && Array.isArray(skipped) && applied.some(item =>
474
+ typeof path(item) === 'string' && path(item).trim() && !skipped.some(other => path(other) === path(item))
475
+ && !(Array.isArray(failures) && failures.some(failure => failure?.code === 'undo_not_verified'
476
+ && (!failure.file || failure.file === path(item)))));
477
+ });
478
+ }
479
+
480
+ // Generic reasons say nothing beyond "no files were written", so they stay in
481
+ // the details. Both locales match because a report keeps its original locale.
482
+ const GENERIC_UNCHANGED_REASONS = new Set([
483
+ 'No file changes need to sync back to Overleaf.', '没有产生需要同步回 Overleaf 的文件改动。',
484
+ 'No approved file changes remain to sync back to Overleaf.', '没有剩余已确认的文件改动需要同步回 Overleaf。',
485
+ 'This run was Ask mode.', '这轮是只问不改。'
486
+ ]);
487
+
488
+ // One summary line for the meta rows: what was written, whether it can be
489
+ // undone, and a pending save. The rows themselves stay behind the disclosure,
490
+ // which opens by itself only when something needs the user's attention.
491
+ function summarizeCompletionMeta(rows, run, { tx, failed = false, unconfirmed = false } = {}) {
492
+ const find = key => String(rows.find(row => row?.key === key)?.value || '').trim();
493
+ const counts = find('writeResult').match(/wrote (\d+) items?, skipped (\d+)|已写入 (\d+) 项,跳过 (\d+) 项/);
494
+ const wrote = Number(counts?.[1] ?? counts?.[3] ?? 0), skipped = Number(counts?.[2] ?? counts?.[4] ?? 0);
495
+ const reason = find('unchangedReason');
496
+ const undone = run?.undoStatus === 'applied' || run?.trackedChangeStatus === 'rejected';
497
+ const retentionOmitted = hasOmittedRecoveryRetention(run);
498
+ const undoUnavailable = rows.some(row => row?.undoUnavailable === true);
499
+ const savePending = !undone && (Boolean(run?.saveCheck) || (!run?.saveConfirmedAt && /^(?:Pending|待确认)/.test(find('saveState'))));
500
+ const pendingFileUndo = run?.trackedChangeStatus === 'needs_review'
501
+ && run.undoOperations?.some(operation => operation?.undoCreatedFile?.v === 1 || operation?.type === 'create');
502
+ const facts = [];
503
+ if (failed) facts.push({ key: 'write', tone: 'fail', text: unconfirmed ? tx('Write not confirmed', '写入结果未确认') : tx('Write incomplete', '写入未完成') });
504
+ else if (wrote) facts.push({ key: 'write', text: tx(`Wrote ${wrote} item${wrote === 1 ? '' : 's'}`, `已写入 ${wrote} 项`) });
505
+ else if (reason && !GENERIC_UNCHANGED_REASONS.has(reason)) facts.push({ key: 'write', text: tx('Not written: ', '未写入:') + reason.replace(/[.。]$/, '') });
506
+ else facts.push({ key: 'write', text: tx('No files written', '未写入文件') });
507
+ if (skipped) facts.push({ key: 'skip', tone: 'warn', text: tx(`${skipped} skipped`, `跳过 ${skipped} 项`) });
508
+ const undoable = Number(find('undo').match(/(\d+)/)?.[1] || 0);
509
+ const undo = run?.undoStatus === 'running' || /^(?:Undoing changes\.\.\.|正在撤销修改…)$/.test(find('undo')) ? tx('Undoing…', '正在撤销…')
510
+ : run?.undoStatus === 'applied' || run?.trackedChangeStatus === 'rejected' ? tx('Changes undone', '已撤销本轮修改')
511
+ : run?.trackedChangeStatus === 'accepted' ? tx('Changes accepted', '修改已接受')
512
+ : pendingFileUndo || run?.undoStatus === 'partial'
513
+ ? (hasUndoProgress(run) ? tx('Partly undone', '已撤销部分修改') : tx('Undo incomplete', '撤销未完成'))
514
+ : undoable ? tx(`${undoable} undoable`, `可撤销 ${undoable} 项`)
515
+ // With nothing written, "Nothing to undo" repeats the write fact; the
516
+ // expanded details still carry the full Undo row.
517
+ : undoUnavailable ? tx('Undo incomplete', '撤销未完成')
518
+ : failed || retentionOmitted || !wrote ? '' : tx('Nothing to undo', '无可撤销写入');
519
+ if (undo) facts.push({ key: 'undo', text: undo });
520
+ if (retentionOmitted) facts.push({ key: 'recoveryRetention', tone: 'warn',
521
+ text: run.recoveryRetention.coreRetained === true
522
+ ? tx('Folder observations not retained after reload', '刷新后文件夹观测记录未保留')
523
+ : tx('Recovery data not retained after reload', '刷新后恢复材料未保留') });
524
+ if (savePending) facts.push({ key: 'save', tone: 'warn', text: tx('Save pending', '保存待确认') });
525
+ const attention = failed || skipped > 0 || retentionOmitted || undoUnavailable;
526
+ const warnKeys = new Set([...(failed || skipped > 0 ? ['writeResult', 'nextStep'] : []),
527
+ ...(retentionOmitted || undoUnavailable ? ['recoveryRetention'] : []), ...(savePending ? ['saveState'] : [])]);
528
+ return { facts, open: attention, warnKeys };
529
+ }
530
+
531
+ return { splitFlatCompletionReport, projectNotStartedRun, projectFinishedRun, projectCompletionConclusion, projectCompletionBody, projectCompletionMeta, summarizeCompletionMeta };
532
+ });