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
@@ -171,6 +171,25 @@
171
171
 
172
172
  function createCoordinator(options = {}) {
173
173
  let view = null;
174
+ const guidanceAttempts = new Map();
175
+ const guidanceKey = (sessionId, itemId) => JSON.stringify([sessionId, itemId]);
176
+ const matchesGuidanceItem = (item, identity) => item?.id === identity.itemId
177
+ && item.createdAt === identity.itemCreatedAt && item.text === identity.text
178
+ && item.clientUserMessageId === identity.clientUserMessageId;
179
+ const matchesGuidanceScope = expected => {
180
+ const scope = options.getGuideScope?.();
181
+ return !expected || ['projectId', 'accountScopeId', 'generation'].every(key => scope?.[key] === expected[key]);
182
+ };
183
+ const guideErrorKeys = {
184
+ codex_turn_not_ready: 'queuedInputGuideNotReady',
185
+ codex_turn_mismatch: 'queuedInputGuideTurnChanged',
186
+ codex_steer_empty: 'queuedInputGuideEmpty',
187
+ codex_steer_too_large: 'queuedInputGuideTooLarge'
188
+ };
189
+ function formatGuideError(error) {
190
+ const key = guideErrorKeys[error?.code];
191
+ return (key && options.tr?.(key)) || error?.message || options.tr?.('queuedInputGuideFailed');
192
+ }
174
193
  const scheduler = create({
175
194
  getQueue: sessionId => options.getSession?.(sessionId)?.pendingInputs || [],
176
195
  setQueue: (sessionId, pendingInputs) => options.setQueue?.(sessionId, pendingInputs),
@@ -226,62 +245,143 @@
226
245
  const item = (session?.pendingInputs || []).find(entry => entry.id === itemId);
227
246
  const run = options.getCurrentRun?.();
228
247
  const binding = run?.activeTurn;
229
- if (!session || !item || !run || run.sessionId !== session.id || !binding?.threadId || !binding?.turnId) {
248
+ const initialScope = options.getGuideScope?.();
249
+ const guideScope = initialScope ? { ...initialScope } : null;
250
+ if (!session || !item || item.status !== 'queued' || !run || run.sessionId !== session.id || !binding?.threadId || !binding?.turnId) {
230
251
  options.toast?.(options.tr?.('queuedInputGuideUnavailable'), 'warning');
231
252
  return;
232
253
  }
254
+ const identity = { requestId: run.nativeRequestId, threadId: binding.threadId, turnId: binding.turnId,
255
+ projectKey: run.runProjectId, recordId: run.recordId, sessionId: session.id, sessionCreatedAt: session.createdAt,
256
+ itemId, itemCreatedAt: item.createdAt, text: item.text, clientUserMessageId: item.clientUserMessageId };
257
+ const attemptKey = guidanceKey(identity.sessionId, itemId);
258
+ const attempt = { identity, scope: guideScope, previewToken: null };
259
+ const sameScope = () => matchesGuidanceScope(guideScope);
260
+ const ownedSession = () => {
261
+ const latest = options.getSession?.(identity.sessionId);
262
+ return sameScope() && latest?.createdAt === identity.sessionCreatedAt ? latest : null;
263
+ };
264
+ const ownedItemSession = (allowPaused = false) => {
265
+ const latest = ownedSession();
266
+ const current = latest?.pendingInputs?.find(entry => entry.id === itemId);
267
+ return guidanceAttempts.get(attemptKey) === attempt
268
+ && (current?.status === 'steering' || (allowPaused && current?.status === 'paused'))
269
+ && matchesGuidanceItem(current, identity) ? latest : null;
270
+ };
271
+ function clearPreview() {
272
+ const token = attempt.previewToken;
273
+ attempt.previewToken = null;
274
+ if (token) options.clearGuidancePreview?.(token);
275
+ }
276
+ const assertCurrent = () => {
277
+ const currentRun = options.getCurrentRun?.();
278
+ if (options.isGuideCancelled?.() || currentRun !== run || options.getActiveSession?.()?.id !== identity.sessionId
279
+ || currentRun?.nativeRequestId !== identity.requestId
280
+ || currentRun?.recordId !== identity.recordId || currentRun?.runProjectId !== identity.projectKey
281
+ || currentRun?.activeTurn?.threadId !== identity.threadId || currentRun?.activeTurn?.turnId !== identity.turnId
282
+ || !ownedItemSession()) {
283
+ throw Object.assign(new Error(options.tr?.('queuedInputGuideUnavailable') || 'Guidance is no longer available for this turn.'), { code: 'codex_turn_mismatch' });
284
+ }
285
+ };
286
+ async function restoreUnsent(error) {
287
+ // No steer was dispatched. Restore only this attempt's still-owned item;
288
+ // a concurrent removal, replacement or scope switch must stay untouched.
289
+ clearPreview();
290
+ const latest = ownedItemSession();
291
+ if (latest) {
292
+ options.setQueue?.(identity.sessionId, Queue.returnToQueue(latest.pendingInputs, itemId));
293
+ render();
294
+ try {
295
+ await options.save?.({ queueMutation: { type: 'transition', sessionId: identity.sessionId, itemId } });
296
+ } catch (recoveryError) {
297
+ if (error && typeof error === 'object') error.persistenceRecoveryError = recoveryError?.message || String(recoveryError);
298
+ }
299
+ }
300
+ }
233
301
  options.setQueue?.(session.id, Queue.markSteering(session.pendingInputs, itemId));
234
- render();
235
- await options.save?.({
236
- queueMutation: { type: 'transition', sessionId: session.id, itemId }
237
- });
302
+ guidanceAttempts.set(attemptKey, attempt);
238
303
  try {
239
- const response = await options.sendSteer?.({
240
- requestId: run.nativeRequestId,
241
- projectKey: run.runProjectId,
242
- threadId: binding.threadId,
243
- expectedTurnId: binding.turnId,
244
- clientUserMessageId: item.clientUserMessageId,
245
- input: [{ type: 'text', text: item.text }]
246
- });
247
- const latest = options.getSession?.(session.id);
248
- options.setQueue?.(
249
- session.id,
250
- response?.ok
251
- ? Queue.remove(latest?.pendingInputs || [], itemId)
252
- : Queue.returnToQueue(latest?.pendingInputs || [], itemId, response?.error?.code || 'steer_failed')
304
+ try {
305
+ attempt.previewToken = options.previewGuidance?.(identity.text, {
306
+ guidanceId: itemId, sessionId: identity.sessionId, recordId: identity.recordId,
307
+ sessionCreatedAt: identity.sessionCreatedAt
308
+ });
309
+ render();
310
+ const preflight = await options.preflightGuide?.({ task: identity.text, assertCurrent });
311
+ assertCurrent();
312
+ if (preflight?.blocked) throw Object.assign(new Error('Guidance was blocked before dispatch.'), { guidanceBlocked: true });
313
+ await options.save?.({
314
+ queueMutation: { type: 'transition', sessionId: identity.sessionId, itemId }
315
+ });
316
+ assertCurrent();
317
+ } catch (error) {
318
+ await restoreUnsent(error);
319
+ if (error?.guidanceBlocked) return { ok: false, blocked: true };
320
+ throw error;
321
+ }
322
+ let response;
323
+ try {
324
+ response = await options.sendSteer?.({
325
+ requestId: identity.requestId,
326
+ projectKey: identity.projectKey,
327
+ threadId: identity.threadId,
328
+ expectedTurnId: identity.turnId,
329
+ clientUserMessageId: identity.clientUserMessageId,
330
+ input: [{ type: 'text', text: identity.text }]
331
+ }, { assertCurrent });
332
+ } catch (error) {
333
+ if (error?.requestDispatched === false) { await restoreUnsent(error); throw error; }
334
+ clearPreview();
335
+ const latest = ownedItemSession(true);
336
+ if (latest) {
337
+ const paused = latest.pendingInputs.map(entry => entry.id === itemId
338
+ ? { ...entry, status: 'paused', pauseReason: 'steer_delivery_unknown' }
339
+ : entry);
340
+ options.setQueue?.(identity.sessionId, Queue.normalizeQueue(paused));
341
+ }
342
+ options.toast?.(formatGuideError(error), 'warning');
343
+ render();
344
+ if (latest) await options.save?.({
345
+ queueMutation: { type: 'transition', sessionId: identity.sessionId, itemId }
346
+ });
347
+ return;
348
+ }
349
+ // Cancellation can pause the owned row while a dispatched steer is
350
+ // awaiting acknowledgement. A confirmed receipt also consumes that
351
+ // paused copy, so Resume cannot send an already accepted input again.
352
+ const latest = ownedItemSession(Boolean(response?.ok));
353
+ if (latest) options.setQueue?.(
354
+ identity.sessionId,
355
+ response?.ok ? Queue.remove(latest.pendingInputs, itemId)
356
+ : Queue.returnToQueue(latest.pendingInputs, itemId, response?.error?.code || 'steer_failed')
253
357
  );
254
358
  if (response?.ok) {
255
- if (typeof options.appendGuidance === 'function') {
256
- options.appendGuidance(item.text, {
257
- guidanceId: item.id,
258
- sessionId: session.id,
259
- recordId: run.recordId,
260
- status: 'completed'
359
+ if (ownedSession() && typeof options.appendGuidance === 'function') {
360
+ options.appendGuidance(identity.text, {
361
+ guidanceId: itemId, sessionId: identity.sessionId, recordId: identity.recordId,
362
+ sessionCreatedAt: identity.sessionCreatedAt, status: 'completed',
363
+ timestamp: attempt.previewToken?.event?.timestamp
261
364
  });
262
- } else {
365
+ } else if (ownedSession() && options.getCurrentRun?.() === run) {
263
366
  options.appendEvent?.(options.tr?.('queuedInputGuided'), 'completed');
264
367
  }
265
368
  } else {
266
- options.toast?.(response?.error?.message || options.tr?.('queuedInputGuideFailed'), 'warning');
369
+ options.toast?.(formatGuideError(response?.error), 'warning');
267
370
  }
371
+ clearPreview();
268
372
  render();
269
- await options.save?.({
373
+ // A Native acknowledgement is final delivery evidence. A later local
374
+ // save failure must not turn it into an unknown delivery or resend it.
375
+ if (latest) await options.save?.({
270
376
  queueMutation: response?.ok
271
- ? { type: 'remove', sessionId: session.id, itemId }
272
- : { type: 'transition', sessionId: session.id, itemId }
377
+ ? { type: 'remove', sessionId: identity.sessionId, itemId }
378
+ : { type: 'transition', sessionId: identity.sessionId, itemId }
273
379
  });
274
- } catch (error) {
275
- const latest = options.getSession?.(session.id);
276
- const paused = (latest?.pendingInputs || []).map(entry => entry.id === itemId
277
- ? { ...entry, status: 'paused', pauseReason: 'steer_delivery_unknown' }
278
- : entry);
279
- options.setQueue?.(session.id, Queue.normalizeQueue(paused));
280
- options.toast?.(error?.message || options.tr?.('queuedInputGuideFailed'), 'warning');
380
+ else if (response?.ok && ownedSession()) await options.save?.();
381
+ } finally {
382
+ clearPreview();
383
+ if (guidanceAttempts.get(attemptKey) === attempt) guidanceAttempts.delete(attemptKey);
281
384
  render();
282
- await options.save?.({
283
- queueMutation: { type: 'transition', sessionId: session.id, itemId }
284
- });
285
385
  }
286
386
  }
287
387
 
@@ -320,7 +420,7 @@
320
420
  view = options.createView?.({
321
421
  container,
322
422
  tr: options.tr,
323
- onGuide: itemId => guide(itemId).catch(error => options.toast?.(error.message, 'warning')),
423
+ onGuide: itemId => guide(itemId).catch(error => options.toast?.(formatGuideError(error), 'warning')),
324
424
  onRemove: remove,
325
425
  onResume: resume,
326
426
  onToast: message => options.toast?.(message, 'warning')
@@ -331,19 +431,29 @@
331
431
  }
332
432
  const session = options.getActiveSession?.();
333
433
  const run = options.getCurrentRun?.();
334
- view?.render?.(session?.pendingInputs || [], {
434
+ const visibleQueue = (session?.pendingInputs || []).filter(item => {
435
+ const attempt = guidanceAttempts.get(guidanceKey(session.id, item.id));
436
+ return !attempt?.previewToken || !['steering', 'paused'].includes(item.status)
437
+ || !matchesGuidanceScope(attempt.scope)
438
+ || session.createdAt !== attempt.identity.sessionCreatedAt || !matchesGuidanceItem(item, attempt.identity);
439
+ });
440
+ view?.render?.(visibleQueue, {
335
441
  running: Boolean(run),
336
442
  canGuide: Boolean(run?.activeTurn?.turnId)
337
443
  });
338
444
  }
339
445
 
340
446
  async function startQueuedInput(item, sessionId) {
341
- if (options.canStart?.(sessionId) === false || options.getCurrentRun?.()) {
342
- const error = new Error('Queued input cannot start in the current session.');
343
- error.code = 'queue_session_not_active';
344
- throw error;
345
- }
447
+ const assertCanStart = () => {
448
+ if (options.canStart?.(sessionId) === false || options.getCurrentRun?.()) {
449
+ const error = new Error('Queued input cannot start in the current session.');
450
+ error.code = 'queue_session_not_active';
451
+ throw error;
452
+ }
453
+ };
454
+ assertCanStart();
346
455
  await options.applyQueuedInput?.(item);
456
+ assertCanStart();
347
457
  await options.run?.(item);
348
458
  }
349
459
 
@@ -0,0 +1,147 @@
1
+ (function (root, factory) {
2
+ if (typeof module === 'object' && module.exports) module.exports = factory();
3
+ else root.CodexOverleafModuleRegistry.define('RunRecoveryActions', [], factory);
4
+ })(typeof window !== 'undefined' ? window : globalThis, function () {
5
+ 'use strict';
6
+
7
+ function formatForceCancelOutcome(response, tx) {
8
+ if (response?.ok !== true) return tx('Release failed — refresh the tab, then try again.', '释放失败,请刷新页面后重试。');
9
+ const result = response.result || response;
10
+ if (result.lockReleased === true) return tx('Force-released — you can retry the run.', '已释放,可以重试本轮任务。');
11
+ if (result.cancelled === true) return tx('Cancellation requested. Wait for the task to stop before retrying.', '已请求取消,请等待任务停止后再重试。');
12
+ if (result.cancelled === false) return tx('No active task matched the release request.', '没有发现与释放请求匹配的活动任务。');
13
+ if (result.sent === true) return tx('Cancellation request sent; release is not yet confirmed.', '取消请求已发送,释放状态尚未确认。');
14
+ return tx('Release is not yet confirmed. Check the current task before retrying.', '释放状态尚未确认,请检查当前任务后再重试。');
15
+ }
16
+
17
+ function create(deps = {}) {
18
+ const { tx, failureNotice, refillComposerForRetry, openProjectFileForFailure, showNativeSetupGuidance,
19
+ openStorageSettings, forceCancelStuckTaskForCurrentProject } = deps;
20
+ const document = deps.document || globalThis.document;
21
+ const window = deps.window || globalThis.window;
22
+
23
+ const RETRYABLE_FAILURE_CODES = new Set([
24
+ 'native_request_failed', 'codex_timeout', 'codex_no_usable_result',
25
+ 'codex_output_limit', 'codex_context_limit', 'stale_source_changed', 'patch_anchor_not_found',
26
+ 'target_editor_not_ready', 'write_timeout', 'partial_write_needs_review',
27
+ 'write_operation_failed'
28
+ ]);
29
+ const NATIVE_SETUP_FAILURE_CODES = new Set([
30
+ 'native_bridge_unavailable', 'native_protocol_incompatible'
31
+ ]);
32
+ const OPEN_FILE_FAILURE_CODES = new Set([
33
+ 'target_file_not_active', 'target_file_open_failed', 'target_file_not_found'
34
+ ]);
35
+ const STORAGE_FAILURE_CODES = new Set([
36
+ 'storage_quota_exceeded', 'run_state_persist_failed'
37
+ ]);
38
+ // A missing `codex` binary is fixed in the terminal, not the panel — the
39
+ // action opens the README troubleshooting section (PATH / login checks).
40
+ const CODEX_INSTALL_FAILURE_CODES = new Set(['codex_not_found']);
41
+ const CODEX_CLI_TROUBLESHOOTING_URL = 'https://github.com/Ghqqqq/codex-overleaf-link#faq-and-troubleshooting';
42
+
43
+ function buildRecoveryButton(failureCode, label, title) {
44
+ const button = document.createElement('button');
45
+ button.type = 'button';
46
+ button.className = 'run-final-answer__recovery-action';
47
+ button.dataset.recoveryFor = failureCode;
48
+ button.textContent = label;
49
+ if (title) {
50
+ button.title = title;
51
+ }
52
+ return button;
53
+ }
54
+
55
+ function appendEditAndResend(report, run, actionKey) {
56
+ const retry = buildRecoveryButton(actionKey,
57
+ tx('Edit & resend', '编辑后重发'),
58
+ tx('Put this run\u2019s task back into the composer so you can adjust and resend it.', '把本轮任务填回输入框,修改后可直接重发。'));
59
+ retry.dataset.recoveryAction = 'edit-resend';
60
+ retry.addEventListener('click', clickEvent => {
61
+ clickEvent.stopPropagation();
62
+ refillComposerForRetry(run || null);
63
+ });
64
+ report.append(retry);
65
+ }
66
+
67
+ function appendRecoveryActionForFailure(report, event, run) {
68
+ const recovery = failureNotice?.prepareRecovery(report, { openFile: openProjectFileForFailure });
69
+ const failure = recovery?.failure || event?.failure;
70
+ const failureCode = failure?.code || '';
71
+ if (recovery?.handled) return;
72
+ report = recovery?.target || report;
73
+ if (RETRYABLE_FAILURE_CODES.has(failureCode) && typeof refillComposerForRetry === 'function') {
74
+ appendEditAndResend(report, run, failureCode);
75
+ return;
76
+ }
77
+ if (NATIVE_SETUP_FAILURE_CODES.has(failureCode) && typeof showNativeSetupGuidance === 'function') {
78
+ const fix = buildRecoveryButton(failureCode,
79
+ tx('Fix the native host', '修复 native host'),
80
+ tx('Opens the install/update guidance with the copyable command.', '打开安装/更新指引(含可复制命令)。'));
81
+ fix.addEventListener('click', clickEvent => {
82
+ clickEvent.stopPropagation();
83
+ showNativeSetupGuidance();
84
+ });
85
+ report.append(fix);
86
+ return;
87
+ }
88
+ if (OPEN_FILE_FAILURE_CODES.has(failureCode) && failure?.file && typeof openProjectFileForFailure === 'function') {
89
+ const open = buildRecoveryButton(failureCode,
90
+ tx(`Open ${failure.file} in Overleaf`, `在 Overleaf 打开 ${failure.file}`), '');
91
+ open.addEventListener('click', clickEvent => {
92
+ clickEvent.stopPropagation();
93
+ openProjectFileForFailure(failure.file);
94
+ });
95
+ report.append(open);
96
+ return;
97
+ }
98
+ if (STORAGE_FAILURE_CODES.has(failureCode) && typeof openStorageSettings === 'function') {
99
+ const clean = buildRecoveryButton(failureCode,
100
+ tx('Open history & storage cleanup', '打开历史与存储清理'), '');
101
+ clean.addEventListener('click', clickEvent => {
102
+ clickEvent.stopPropagation();
103
+ openStorageSettings();
104
+ });
105
+ report.append(clean);
106
+ return;
107
+ }
108
+ if (CODEX_INSTALL_FAILURE_CODES.has(failureCode)) {
109
+ const guide = buildRecoveryButton(failureCode,
110
+ tx('Codex CLI troubleshooting guide', '查看 Codex CLI 排查指引'),
111
+ tx('Opens the README section on fixing a missing `codex` command (PATH / login).', '打开 README 中「找不到 codex 命令」的排查说明(PATH/登录)。'));
112
+ guide.addEventListener('click', clickEvent => {
113
+ clickEvent.stopPropagation();
114
+ window.open(CODEX_CLI_TROUBLESHOOTING_URL, '_blank', 'noopener');
115
+ });
116
+ report.append(guide);
117
+ return;
118
+ }
119
+ if (failureCode !== 'codex_project_locked') return;
120
+ const button = document.createElement('button');
121
+ button.type = 'button';
122
+ button.className = 'run-final-answer__recovery-action';
123
+ button.dataset.recoveryFor = failureCode;
124
+ button.textContent = tx('Force-release the stuck task', '强制释放卡住的任务');
125
+ button.title = tx(
126
+ 'Requests cancellation for this project. A sent request does not confirm that the task has stopped. Use this when refreshing the tab did not free the project.',
127
+ '请求取消当前项目的任务;请求已发送不代表任务已经停止。刷新页面仍未解除占用时使用。'
128
+ );
129
+ button.addEventListener('click', event => {
130
+ event.stopPropagation();
131
+ button.disabled = true;
132
+ button.textContent = tx('Releasing…', '正在释放…');
133
+ forceCancelStuckTaskForCurrentProject().then(result => {
134
+ button.textContent = formatForceCancelOutcome(result, tx);
135
+ button.disabled = result?.ok === true;
136
+ }).catch(() => {
137
+ button.disabled = false;
138
+ button.textContent = tx('Release failed — refresh the tab, then try again.', '释放失败,请刷新页面后重试。');
139
+ });
140
+ });
141
+ report.append(button);
142
+ }
143
+
144
+ return { buildRecoveryButton, appendRecoveryActionForFailure };
145
+ }
146
+ return { create, formatForceCancelOutcome };
147
+ });
@@ -1,14 +1,15 @@
1
- (function initCodexOverleafRunResultActions(root, factory) {
2
- const api = factory();
3
- if (typeof module === 'object' && module.exports) module.exports = api;
4
- if (root) root.CodexOverleafRunResultActions = api;
5
- })(typeof window !== 'undefined' ? window : globalThis, function runResultActionsFactory() {
1
+ (function (root, factory) {
2
+ if (typeof module === 'object' && module.exports) module.exports = factory(require('./runCompletionMeta'));
3
+ else root.CodexOverleafModuleRegistry.define('RunResultActions', ['RunCompletionMeta'], factory);
4
+ })(typeof window !== 'undefined' ? window : globalThis, function (RunCompletionMeta) {
6
5
  'use strict';
7
6
 
7
+ const { splitFlatCompletionReport, projectNotStartedRun, projectFinishedRun, projectCompletionConclusion, projectCompletionBody, projectCompletionMeta, summarizeCompletionMeta } = RunCompletionMeta;
8
+
9
+
8
10
  function create(deps = {}) {
9
11
  const {
10
12
  tr,
11
- getLocale,
12
13
  forkRunFromNode,
13
14
  document: documentRef = globalThis.document,
14
15
  navigator: navigatorRef = globalThis.navigator
@@ -19,11 +20,27 @@
19
20
  const reports = root.querySelectorAll('.run-completion-report');
20
21
  const report = reports[reports.length - 1];
21
22
  if (!actions || !report) return;
22
- report.append(actions);
23
+ // Share one row with the facts summary when it exists; the expandable
24
+ // details list stays below both.
25
+ const factsToggle = report.querySelector(':scope > .run-final-answer__facts > .run-final-answer__facts-sum');
26
+ if (factsToggle) factsToggle.after(actions);
27
+ else {
28
+ // Without a facts summary, the report's own Details toggle shares the row.
29
+ const details = report.querySelector(':scope > .run-outcome-details, :scope > .run-outcome-footer > .run-outcome-details');
30
+ if (details) {
31
+ let footer = details.parentElement.matches('.run-outcome-footer') ? details.parentElement : null;
32
+ if (!footer) {
33
+ footer = documentRef.createElement('div');
34
+ footer.className = 'run-outcome-footer';
35
+ details.before(footer);
36
+ footer.append(details);
37
+ }
38
+ footer.append(actions);
39
+ } else report.append(actions);
40
+ }
23
41
  actions.hidden = false;
24
42
  configureCopyButton(root);
25
43
  configureForkButton(root, run);
26
- configureCompletedTime(actions, run);
27
44
  }
28
45
 
29
46
  function configureForkButton(root, run) {
@@ -38,7 +55,7 @@
38
55
  button.disabled = !available;
39
56
  button.title = tr(available ? 'forkRunTitle' : 'forkRunUnavailable');
40
57
  button.setAttribute('aria-label', button.title);
41
- button.innerHTML = '<svg viewBox="0 0 20 20" aria-hidden="true"><path d="M3 10h3.5c3.5 0 4.2-4 7.5-4h2m-2.5-2.5L16 6l-2.5 2.5M6.5 10c3.5 0 4.2 4 7.5 4h2m-2.5-2.5L16 14l-2.5 2.5"/></svg>';
58
+ button.innerHTML = '<svg viewBox="0 0 20 20" aria-hidden="true"><path transform="translate(.5 0)" d="M3 10h3.5c3.5 0 4.2-4 7.5-4h2m-2.5-2.5L16 6l-2.5 2.5M6.5 10c3.5 0 4.2 4 7.5 4h2m-2.5-2.5L16 14l-2.5 2.5"/></svg>';
42
59
  if (!available) return;
43
60
  button.addEventListener('click', async event => {
44
61
  event.stopPropagation();
@@ -53,19 +70,6 @@
53
70
  });
54
71
  }
55
72
 
56
- function configureCompletedTime(actions, run) {
57
- const target = actions.querySelector('[data-run-completed-time]');
58
- const finishedAt = String(run.finishedAt || '').trim();
59
- const date = finishedAt ? new Date(finishedAt) : null;
60
- if (!target || !date || !Number.isFinite(date.getTime())) return;
61
- target.hidden = false;
62
- target.textContent = new Intl.DateTimeFormat(getLocale(), {
63
- hour: '2-digit',
64
- minute: '2-digit'
65
- }).format(date);
66
- target.title = date.toLocaleString(getLocale());
67
- }
68
-
69
73
  function configureCopyButton(root) {
70
74
  const existing = root.querySelector('[data-run-copy]');
71
75
  const button = existing.cloneNode(true);
@@ -76,7 +80,7 @@
76
80
  button.disabled = !copyText;
77
81
  button.title = tr('copyConclusionTitle');
78
82
  button.setAttribute('aria-label', button.title);
79
- button.innerHTML = '<svg viewBox="0 0 20 20" aria-hidden="true"><rect x="6.5" y="6.5" width="9" height="9" rx="2"/><path d="M13.5 6.5V5a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v6.5a2 2 0 0 0 2 2h1.5"/></svg>';
83
+ button.innerHTML = '<svg viewBox="0 0 20 20" aria-hidden="true"><g transform="translate(.75 .75)"><rect x="6.5" y="6.5" width="9" height="9" rx="2"/><path d="M13.5 6.5V5a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v6.5a2 2 0 0 0 2 2h1.5"/></g></svg>';
80
84
  if (!copyText) return;
81
85
  button.addEventListener('click', async event => {
82
86
  event.stopPropagation();
@@ -107,128 +111,9 @@
107
111
  if (!copied) throw new Error('clipboard_copy_failed');
108
112
  }
109
113
 
110
- return Object.freeze({ configureResultActions, projectUndoAvailability, splitFlatCompletionReport, projectCompletionMeta, summarizeCompletionMeta });
111
- }
112
-
113
-
114
- const FLAT_REPORT_STATUS_SECTIONS = [
115
- { key: 'saveState', prefixes: ['Save:', '保存:'], en: 'Save', zh: '保存' },
116
- { key: 'unchangedReason', prefixes: ['Why nothing changed:', '未修改原因:'], en: 'Why nothing changed', zh: '未修改原因' },
117
- { key: 'writeResult', prefixes: ['Write result:', '写入结果:'], en: 'Write result', zh: '写入结果' },
118
- { key: 'undo', prefixes: ['Undo:', '可撤销:'], en: 'Undo', zh: '可撤销' },
119
- { key: 'nextStep', prefixes: ['Next:', '下一步:'], en: 'Next', zh: '下一步' }
120
- ];
121
-
122
- function splitFlatCompletionReport(text, tx = value => value) {
123
- const raw = typeof text === 'string' ? text : '';
124
- if (!raw.trim()) return { body: raw, meta: [] };
125
- const bodySections = [], meta = [];
126
- for (const section of raw.split(/\n{2,}/)) {
127
- const trimmed = section.trim();
128
- if (!trimmed) continue;
129
- const entry = trimmed.includes('\n') ? null
130
- : FLAT_REPORT_STATUS_SECTIONS.find(item => item.prefixes.some(prefix => trimmed.startsWith(prefix)));
131
- if (entry) {
132
- const prefix = entry.prefixes.find(value => trimmed.startsWith(value));
133
- const value = trimmed.slice(prefix.length).trim();
134
- if (value) {
135
- meta.push({ key: entry.key, label: tx(entry.en, entry.zh), value });
136
- continue;
137
- }
138
- }
139
- bodySections.push(trimmed);
140
- }
141
- return { body: bodySections.join('\n\n'), meta };
142
- }
143
-
144
- function projectCompletionMeta(meta, run, options) {
145
- const { tx, trackedChangeInFlight, isTrackedChangeLifecycleRun, projectRunSettlement } = options;
146
- if (!Array.isArray(meta) || !run) return meta;
147
- const undone = run.undoStatus === 'applied' || run.trackedChangeStatus === 'rejected';
148
- const rows = undone ? meta.filter(row => row?.key !== 'saveState') : meta;
149
- const inFlight = trackedChangeInFlight?.get(run.id);
150
- const tracked = isTrackedChangeLifecycleRun(run);
151
- const pendingCreatedUndo = tracked && run.trackedChangeStatus === 'needs_review' && run.undoOperations?.some(operation => operation?.undoCreatedFile?.v === 1);
152
- let undo;
153
- if (run.undoStatus === 'running' || inFlight === 'reject') {
154
- undo = tx('Undoing changes...', '正在撤销修改…');
155
- } else if (run.undoStatus === 'applied' || (tracked && run.trackedChangeStatus === 'rejected')) {
156
- undo = tx('This run\'s changes have been undone.', '本轮修改已撤销。');
157
- } else if (tracked && run.trackedChangeStatus === 'accepted') {
158
- undo = tx('Changes accepted; undo is no longer available.', '修改已接受,无法再撤销。');
159
- } else if (run.undoStatus === 'partial' || pendingCreatedUndo) {
160
- undo = hasUndoProgress(run)
161
- ? projectRunSettlement(run).canUndo
162
- ? tx(pendingCreatedUndo ? 'Some changes were undone; retry the remaining files.' : 'Some changes were undone; remaining changes can still be undone.', pendingCreatedUndo ? '已撤销部分修改,可重试剩余文件。' : '已撤销部分修改,剩余修改仍可撤销。')
163
- : tx('Some changes were undone.', '已撤销部分修改。')
164
- : projectRunSettlement(run).canUndo
165
- ? tx(pendingCreatedUndo ? 'Undo is incomplete; retry the remaining files.' : 'Undo is incomplete; remaining changes can still be undone.', pendingCreatedUndo ? '撤销未完成,可重试剩余文件。' : '撤销未完成,剩余修改仍可撤销。')
166
- : tx('Undo is incomplete.', '撤销未完成。');
167
- }
168
- if (!undo && !run.saveCheck && !run.saveConfirmedAt) return rows;
169
- return rows.map(row => row?.key === 'undo' && undo
170
- ? { ...row, label: tx('Undo', '撤销'), value: undo }
171
- : row?.key === 'saveState' && (run.saveCheck || run.saveConfirmedAt)
172
- ? { ...row, label: tx('Save', '保存'), value: run.saveCheck
173
- ? tx('Pending confirmation', '待确认') : tx('Saved', '已确认保存') }
174
- : row);
175
- }
176
-
177
- function hasUndoProgress(run) {
178
- const failures = run?.settlement?.failures || run?.settlementFacts?.failures || [];
179
- return (Array.isArray(run?.events) ? run.events : []).some(event => {
180
- if (!['completed', 'failed'].includes(event?.status) || event.streamRole || ['report', 'guidance', 'stream'].includes(event.kind)) return false;
181
- const count = String(event.title || '').match(/^(?:Undo result: (?:undone|rejected) (\d+) (?:item\(s\)|file\(s\)|tracked change\(s\)), skipped \d+(?: item\(s\))?|撤销结果:已(?:撤销|拒绝) (\d+) (?:项|条留痕|个文件),跳过 \d+ (?:项|条))$/);
182
- if (!(Number(count?.[1] || count?.[2]) > 0)) return false;
183
- const detail = event.detail || {}, applied = detail.Undone || detail.Rejected || detail['已撤销'] || detail['已拒绝'];
184
- const skipped = detail.Skipped || detail['跳过'], path = item => item?.File || item?.['文件'];
185
- return Array.isArray(applied) && Array.isArray(skipped) && applied.some(item =>
186
- typeof path(item) === 'string' && path(item).trim() && !skipped.some(other => path(other) === path(item))
187
- && !(Array.isArray(failures) && failures.some(failure => failure?.code === 'undo_not_verified'
188
- && (!failure.file || failure.file === path(item)))));
189
- });
114
+ return Object.freeze({ configureResultActions, projectUndoAvailability, splitFlatCompletionReport, projectNotStartedRun, projectFinishedRun, projectCompletionConclusion, projectCompletionBody, projectCompletionMeta, summarizeCompletionMeta });
190
115
  }
191
116
 
192
- // Generic reasons say nothing beyond "no files were written", so they stay in
193
- // the details. Both locales match because a report keeps its original locale.
194
- const GENERIC_UNCHANGED_REASONS = new Set([
195
- 'No file changes need to sync back to Overleaf.', '没有产生需要同步回 Overleaf 的文件改动。',
196
- 'No approved file changes remain to sync back to Overleaf.', '没有剩余已确认的文件改动需要同步回 Overleaf。',
197
- 'This run was Ask mode.', '这轮是只问不改。'
198
- ]);
199
-
200
- // One summary line for the meta rows: what was written, whether it can be
201
- // undone, and a pending save. The rows themselves stay behind the disclosure,
202
- // which opens by itself only when something needs the user's attention.
203
- function summarizeCompletionMeta(rows, run, { tx, failed = false, unconfirmed = false } = {}) {
204
- const find = key => String(rows.find(row => row?.key === key)?.value || '').trim();
205
- const counts = find('writeResult').match(/wrote (\d+) items?, skipped (\d+)|已写入 (\d+) 项,跳过 (\d+) 项/);
206
- const wrote = Number(counts?.[1] ?? counts?.[3] ?? 0), skipped = Number(counts?.[2] ?? counts?.[4] ?? 0);
207
- const reason = find('unchangedReason');
208
- const undone = run?.undoStatus === 'applied' || run?.trackedChangeStatus === 'rejected';
209
- const savePending = !undone && (Boolean(run?.saveCheck) || (!run?.saveConfirmedAt && /^(?:Pending|待确认)/.test(find('saveState'))));
210
- const pendingCreatedUndo = run?.trackedChangeStatus === 'needs_review'
211
- && run.undoOperations?.some(operation => operation?.undoCreatedFile?.v === 1);
212
- const facts = [];
213
- if (failed) facts.push({ key: 'write', tone: 'fail', text: unconfirmed ? tx('Write not confirmed', '写入结果未确认') : tx('Write incomplete', '写入未完成') });
214
- else if (wrote) facts.push({ key: 'write', text: tx(`Wrote ${wrote} item${wrote === 1 ? '' : 's'}`, `已写入 ${wrote} 项`) });
215
- else if (reason && !GENERIC_UNCHANGED_REASONS.has(reason)) facts.push({ key: 'write', text: tx('Not written: ', '未写入:') + reason.replace(/[.。]$/, '') });
216
- else facts.push({ key: 'write', text: tx('No files written', '未写入文件') });
217
- if (skipped) facts.push({ key: 'skip', tone: 'warn', text: tx(`${skipped} skipped`, `跳过 ${skipped} 项`) });
218
- const undoable = Number(find('undo').match(/(\d+)/)?.[1] || 0);
219
- const undo = run?.undoStatus === 'running' || /^(?:Undoing changes\.\.\.|正在撤销修改…)$/.test(find('undo')) ? tx('Undoing…', '正在撤销…')
220
- : run?.undoStatus === 'applied' || run?.trackedChangeStatus === 'rejected' ? tx('Changes undone', '已撤销本轮修改')
221
- : run?.trackedChangeStatus === 'accepted' ? tx('Changes accepted', '修改已接受')
222
- : pendingCreatedUndo || run?.undoStatus === 'partial'
223
- ? (hasUndoProgress(run) ? tx('Partly undone', '已撤销部分修改') : tx('Undo incomplete', '撤销未完成'))
224
- : undoable ? tx(`${undoable} undoable`, `可撤销 ${undoable} 项`)
225
- : failed ? '' : tx('Nothing to undo', '无可撤销写入');
226
- if (undo) facts.push({ key: 'undo', text: undo });
227
- if (savePending) facts.push({ key: 'save', tone: 'warn', text: tx('Save pending', '保存待确认') });
228
- const attention = failed || skipped > 0;
229
- const warnKeys = new Set([...(attention ? ['writeResult', 'nextStep'] : []), ...(savePending ? ['saveState'] : [])]);
230
- return { facts, open: attention, warnKeys };
231
- }
232
117
 
233
118
  function projectUndoAvailability(run, projectRunSettlement) {
234
119
  const payload = run.recoveryPayload && typeof run.recoveryPayload === 'object'