codex-overleaf-link 2.5.1 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/README.md +149 -398
  2. package/README.zh-CN.md +153 -404
  3. package/extension/bootstrap/manifest.template.json +1 -1
  4. package/extension/src/background.js +78 -3
  5. package/extension/src/backgroundSharedSessions.js +55 -2
  6. package/extension/src/backgroundUpdateCoordinator.js +128 -12
  7. package/extension/src/content/activeTurnControl.js +164 -7
  8. package/extension/src/content/composerAttachments.js +11 -2
  9. package/extension/src/content/composerPanel.js +92 -12
  10. package/extension/src/content/contentRuntime.js +920 -276
  11. package/extension/src/content/contextTray.js +8 -2
  12. package/extension/src/content/contextUsageView.js +267 -0
  13. package/extension/src/content/diagnosticsPanel.js +18 -0
  14. package/extension/src/content/generated/content.bundle.js +4496 -924
  15. package/extension/src/content/generated/content.bundle.meta.json +6 -3
  16. package/extension/src/content/legacyGlobalRegistry.js +4 -0
  17. package/extension/src/content/localSkillsPanel.js +2 -2
  18. package/extension/src/content/markdownDomRenderer.js +5 -3
  19. package/extension/src/content/markdownText.js +42 -5
  20. package/extension/src/content/modelPicker.js +1 -0
  21. package/extension/src/content/nativeCompatibilityController.js +29 -10
  22. package/extension/src/content/otWarmMirror.js +1 -1
  23. package/extension/src/content/pageBridgeClient.js +2 -2
  24. package/extension/src/content/panelMaintenance.js +6 -2
  25. package/extension/src/content/panelRenderer.js +51 -27
  26. package/extension/src/content/pendingInputView.js +1 -1
  27. package/extension/src/content/projectSettingsCoordinator.js +30 -6
  28. package/extension/src/content/providerSettingsDialog.js +2 -1
  29. package/extension/src/content/recentProjects.js +4 -3
  30. package/extension/src/content/runActivitySummary.js +122 -14
  31. package/extension/src/content/runCompletionMeta.js +194 -6
  32. package/extension/src/content/runController.js +2 -1
  33. package/extension/src/content/runFailureNotice.js +8 -1
  34. package/extension/src/content/runPresence.js +18 -8
  35. package/extension/src/content/runQueueScheduler.js +9 -5
  36. package/extension/src/content/runRecoveryActions.js +2 -1
  37. package/extension/src/content/runResultActions.js +22 -20
  38. package/extension/src/content/runReviewController.js +94 -35
  39. package/extension/src/content/runTimelineView.js +224 -25
  40. package/extension/src/content/scopedPersistencePanelState.js +92 -23
  41. package/extension/src/content/sensitivePreflight.js +14 -1
  42. package/extension/src/content/sessionManager.js +49 -3
  43. package/extension/src/content/sessionMenuView.js +54 -18
  44. package/extension/src/content/sessionPersistence.js +25 -5
  45. package/extension/src/content/settingsPanel.js +15 -2
  46. package/extension/src/content/settingsWorkbench.js +127 -89
  47. package/extension/src/content/sharedSessionViewSync.js +4 -3
  48. package/extension/src/content/subagentActivityView.js +23 -7
  49. package/extension/src/content/updateNotice.js +60 -6
  50. package/extension/src/content/writebackOrchestrator.js +35 -22
  51. package/extension/src/page/compileBridge.js +193 -180
  52. package/extension/src/page/overleafCapabilities.js +4 -1
  53. package/extension/src/page/textCoordinates.js +17 -1
  54. package/extension/src/page/textFileCreator.js +9 -1
  55. package/extension/src/page/writebackRouter.js +24 -77
  56. package/extension/src/pageBridge.js +107 -66
  57. package/extension/src/popup.js +1 -1
  58. package/extension/src/shared/agentTranscript.js +350 -17
  59. package/extension/src/shared/auditRecords.js +2 -0
  60. package/extension/src/shared/compatibility.js +1 -1
  61. package/extension/src/shared/compileAdapter.js +58 -17
  62. package/extension/src/shared/composerAttachmentDraft.js +6 -1
  63. package/extension/src/shared/contextUsage.js +125 -0
  64. package/extension/src/shared/failureReasons.js +42 -3
  65. package/extension/src/shared/i18n.js +299 -29
  66. package/extension/src/shared/lineReferences.js +38 -31
  67. package/extension/src/shared/pageRpcContract.js +1 -1
  68. package/extension/src/shared/panelIcons.js +39 -0
  69. package/extension/src/shared/reviewing.js +130 -1
  70. package/extension/src/shared/runActivityModel.js +70 -4
  71. package/extension/src/shared/runExecutionSnapshot.js +13 -0
  72. package/extension/src/shared/sessionState.js +63 -4
  73. package/extension/src/shared/sessionTitle.js +14 -1
  74. package/extension/src/shared/sharedSessionBridge.js +81 -13
  75. package/extension/src/shared/staleGuard.js +72 -63
  76. package/extension/src/shared/storageDb.js +35 -3
  77. package/extension/src/shared/storageMigration.js +7 -1
  78. package/extension/src/shared/storageRunActions.js +62 -8
  79. package/extension/src/shared/undoOperations.js +126 -28
  80. package/extension/src/shared/updateRuntimeIdentity.js +16 -2
  81. package/extension/src/shared/writebackSettlement.js +21 -3
  82. package/extension/styles/panel.css +1106 -490
  83. package/native-host/src/bootstrapUpdate.js +52 -16
  84. package/native-host/src/chatBridgeRequest.js +60 -6
  85. package/native-host/src/chatBridgeResponse.js +10 -1
  86. package/native-host/src/chatCompletionsBridge.js +29 -2
  87. package/native-host/src/codexPromptAssembly.js +86 -118
  88. package/native-host/src/codexSessionRunner.js +126 -17
  89. package/native-host/src/providerContextError.js +20 -0
  90. package/native-host/src/taskRunnerRuntime.js +18 -6
  91. package/native-host/src/textPatch.js +473 -377
  92. package/native-host/src/updateManager.js +120 -15
  93. package/package.json +5 -8
@@ -3,8 +3,8 @@
3
3
 
4
4
  function create(deps = {}) {
5
5
  if (!deps.RunActivityModel) return null;
6
- const { RunActivityModel: Model, RunGuidanceView, getPendingGuidance = () => [], tx, findRunRecord, getPanel, cssEscape, isGuidance,
7
- formatElapsed, renderMarkdownBlockText, renderRunEvent, presence, classifyStatus, getProjectId, summarizeCommand, projectReviewResultEvent, formatReviewResultFailure,
6
+ const { RunActivityModel: Model, RunGuidanceView, getPendingGuidance = () => [], PanelIcons, tx, findRunRecord, getPanel, cssEscape, isGuidance,
7
+ formatElapsed, renderMarkdownBlockText, renderRunEvent, presence, getNotStartedRun, getFinishedRun, classifyStatus, getProjectId, summarizeCommand, projectReviewResultEvent, formatReviewResultFailure,
8
8
  sanitizeAssistantVisibleText: sanitize = value => String(value || '') } = deps;
9
9
  const cards = new WeakMap();
10
10
  const choices = new Map();
@@ -24,7 +24,7 @@
24
24
  return node;
25
25
  };
26
26
  const subagentView = deps.SubagentActivityView?.create({
27
- tx, getPanel, plain, make, icon, formatElapsed,
27
+ tx, getPanel, plain, plainThought, make, icon, formatElapsed, glyph: name => deps.PanelIcons?.svg?.(name) || '',
28
28
  renderBlocks: (parent, blocks, rows, card, run) => reconcile(parent, blocks, rows,
29
29
  block => buildBlock(block, card), (row, block) => updateBlock(row, block, card, run))
30
30
  });
@@ -229,6 +229,16 @@
229
229
  el.addEventListener('toggle', row.showFull);
230
230
  return row;
231
231
  }
232
+ // One-line thought previews are plain text: drop the Markdown emphasis that
233
+ // reasoning summaries use for their headings ("**Reading the file**").
234
+ function plainThought(value) {
235
+ return String(value || '').replace(/(\*\*|__)(?=\S)([\s\S]*?\S)\1/g, '$2').replace(/\s+/g, ' ').trim();
236
+ }
237
+ // A finished thought that opens with a bold heading is summarised by that heading.
238
+ function thoughtLead(value) {
239
+ const heading = /^\s*(\*\*|__)(?=\S)(.+?\S)\1\s*(?:\n|$)/.exec(String(value || ''));
240
+ return heading ? heading[2].trim() : firstSentence(plainThought(value));
241
+ }
232
242
  function updateThought(row, block, run) {
233
243
  const value = String(sanitize(block.event.title) || '').trim();
234
244
  const live = run.status === 'running' && block.event.status === 'running';
@@ -243,8 +253,7 @@
243
253
  const started = Date.parse(block.startedAt || ''), ended = Date.parse(block.event.timestamp || '');
244
254
  const seconds = Number.isFinite(started) && Number.isFinite(ended) && ended > started ? formatElapsed(ended - started) : '';
245
255
  text(row.label, seconds ? tx('Thought ' + seconds, '思考 ' + seconds) : tx('Thought', '思考'));
246
- const flat = value.replace(/\s+/g, ' ');
247
- text(row.lead, (flat.match(/^.{12,160}?[.!?。!?](?=\s|$)/) || [flat.slice(0, 160)])[0]);
256
+ text(row.lead, thoughtLead(value));
248
257
  row.value = value;
249
258
  row.showFull();
250
259
  }
@@ -378,10 +387,10 @@
378
387
  const live = run.status === 'running' && block.event.status === 'running';
379
388
  const started = Date.parse(block.startedAt || ''), ended = Date.parse(block.event.timestamp || '');
380
389
  const seconds = Number.isFinite(started) && Number.isFinite(ended) && ended > started ? ' ' + formatElapsed(ended - started) : '';
381
- const flat = value.replace(/\s+/g, ' ');
390
+ const flat = plainThought(value);
382
391
  entries.push({ key: block.key, kind: 'thought', state: live ? 'running' : 'completed',
383
392
  label: live ? tx('Thinking', '思考中') : tx('Thought', '思考') + seconds,
384
- lead: live ? (flat.length > 90 ? '…' + flat.slice(-90) : flat) : firstSentence(value) });
393
+ lead: live ? (flat.length > 90 ? '…' + flat.slice(-90) : flat) : thoughtLead(value) });
385
394
  } else if (block.kind !== 'message' && block.kind !== 'guidance') {
386
395
  for (const item of block.kind === 'exploreGroup' ? block.items : [block]) {
387
396
  if (item.kind === 'notice') continue;
@@ -492,6 +501,74 @@
492
501
  if (!choices.has(row.key)) row.el.open = items.some(item => card.projection.notices.some(notice => notice.key === item.key));
493
502
  }
494
503
 
504
+ // Compaction marks sit outside the activity rows: a manual /compact run is
505
+ // shown as a divider, and a completed automatic compaction leaves one above
506
+ // that turn's answer.
507
+ function mountCompaction(root, run) {
508
+ const node = make('div', 'run-compaction');
509
+ node.hidden = true;
510
+ const line = make('div', 'run-compaction-line');
511
+ const label = make('span', 'run-compaction-label');
512
+ const glyph = make('span', 'run-compaction-glyph');
513
+ glyph.setAttribute('aria-hidden', 'true');
514
+ const title = make('span', 'run-compaction-title');
515
+ const time = make('span', 'run-compaction-time');
516
+ label.append(glyph, title, time);
517
+ line.append(label);
518
+ const sub = make('div', 'run-compaction-sub');
519
+ node.append(line, sub);
520
+ if (run.operation === 'compact') {
521
+ root.dataset.operation = 'compact';
522
+ node.dataset.kind = 'manual';
523
+ root.querySelector('[data-run-task]')?.after(node);
524
+ } else {
525
+ node.dataset.kind = 'auto';
526
+ root.querySelector('[data-run-report]')?.before(node);
527
+ }
528
+ return { node, glyph, title, time, sub, icon: '' };
529
+ }
530
+ function renderCompaction(card, run, result) {
531
+ const view = card.compaction;
532
+ if (!view) return;
533
+ const clock = value => {
534
+ const date = new Date(value || '');
535
+ return Number.isFinite(date.getTime())
536
+ ? new Intl.DateTimeFormat(tx('en-US', 'zh-CN'), { hour: '2-digit', minute: '2-digit' }).format(date) : '';
537
+ };
538
+ const setIcon = name => {
539
+ if (view.icon === name) return;
540
+ view.icon = name;
541
+ view.glyph.innerHTML = name ? PanelIcons?.svg(name) || '' : '';
542
+ };
543
+ if (view.node.dataset.kind === 'manual') {
544
+ const state = run.status === 'running' ? 'running' : run.status === 'completed' ? 'completed'
545
+ : ['cancelled', 'rejected'].includes(run.status) ? 'cancelled' : run.status === 'interrupted' ? 'interrupted' : 'failed';
546
+ view.node.dataset.state = state;
547
+ view.node.hidden = false;
548
+ setIcon(state === 'running' ? '' : state === 'completed' ? 'compact' : 'alert');
549
+ text(view.title, ({
550
+ running: tx('Compacting context', '正在压缩上下文'),
551
+ completed: tx('Context compacted', '上下文已压缩'),
552
+ cancelled: tx('Compaction stopped', '已停止压缩'),
553
+ interrupted: tx('Compaction interrupted', '压缩已中断'),
554
+ failed: tx('Compaction did not finish', '压缩未完成')
555
+ })[state]);
556
+ text(view.time, state === 'running' ? '' : clock(run.finishedAt));
557
+ text(view.sub, state === 'running' ? tx('You can keep typing; messages send after compaction.', '可以继续输入,消息会在压缩完成后发送。')
558
+ : state === 'completed' ? tx('Earlier turns were summarized.', '此前的对话已整理为摘要。') : '');
559
+ view.sub.hidden = !view.sub.textContent;
560
+ return;
561
+ }
562
+ view.node.hidden = !result.compactedAt;
563
+ if (!result.compactedAt) return;
564
+ view.node.dataset.state = 'completed';
565
+ setIcon('compact');
566
+ text(view.title, tx('Context compacted automatically', '上下文已自动压缩'));
567
+ text(view.time, clock(result.compactedAt));
568
+ text(view.sub, tx('Codex summarized earlier turns partway through this turn, then continued.',
569
+ 'Codex 在这一轮中途整理了此前的对话,然后继续处理。'));
570
+ view.sub.hidden = false;
571
+ }
495
572
  function mount(root, run) {
496
573
  if (!Model || cards.has(root)) return;
497
574
  const process = root.querySelector('[data-run-process]');
@@ -532,7 +609,7 @@
532
609
  const card = { id: String(run.id), root, process, summary, events, metrics, attention,
533
610
  guidance: root.querySelector('[data-run-guidance]'),
534
611
  busy, rows: new Map(), run, setup: setupEl, setupSummary, setupFold, setupSteps,
535
- peek, peekTrack, peekMore, peekRows: new Map() };
612
+ peek, peekTrack, peekMore, peekRows: new Map(), compaction: mountCompaction(root, run) };
536
613
  const expand = () => { remember(card.id + ':run', true); process.open = true; update({ root, recordId: run.id }); };
537
614
  peek.addEventListener('click', expand);
538
615
  peekMore.addEventListener('click', expand);
@@ -543,9 +620,10 @@
543
620
  events.dataset.expanded = String(process.open);
544
621
  card.attention.hidden = process.open || !card.noticeCount;
545
622
  if (card.projection) {
546
- renderSetup(card, card.projection.setup, card.run.status === 'running');
623
+ const active = card.run.status === 'running' && !card.notStarted;
624
+ renderSetup(card, card.projection.setup, active);
547
625
  if (!process.open) card.setup.hidden = true;
548
- renderPeek(card, card.projection, card.run, card.run.status === 'running');
626
+ renderPeek(card, card.projection, card.run, active);
549
627
  }
550
628
  });
551
629
  render(card, run);
@@ -553,6 +631,21 @@
553
631
  function render(card, run, incoming) {
554
632
  card.run = run;
555
633
  const result = Model.project(run, incoming, { isGuidance });
634
+ const notStarted = getNotStartedRun?.(run);
635
+ const outcome = getFinishedRun?.(run);
636
+ card.root.dataset.outcomeTone = outcome?.tone || '';
637
+ card.root.dataset.outcome = outcome?.kind || '';
638
+ card.notStarted = Boolean(notStarted);
639
+ if (notStarted) {
640
+ // The single launch card replaces preflight workflow/Notice projections.
641
+ // Stored events remain available to history, audit and recovery.
642
+ result.blocks = [];
643
+ result.notices = [];
644
+ result.setup = null;
645
+ }
646
+ // The outcome card owns the visible cause. Keep workflow evidence in the
647
+ // existing process disclosure, without another attention banner.
648
+ if (outcome) result.notices = [];
556
649
  const guidanceEvents = (run.events || []).concat(incoming && !(run.events || []).includes(incoming) ? [incoming] : []);
557
650
  RunGuidanceView?.reconcileGuidance(card.guidance,
558
651
  guidanceEvents.concat(getPendingGuidance(run)),
@@ -560,11 +653,12 @@
560
653
  result.blocks = result.blocks.filter(block => block.kind !== 'guidance');
561
654
  card.projection = result;
562
655
  subagentView?.update(card);
563
- const active = run.status === 'running';
656
+ const active = run.status === 'running' && !notStarted;
564
657
  const abnormal = ['failed', 'interrupted', 'needs_review_after_navigation', 'abandoned_after_navigation'].includes(run.status);
565
- if (choices.has(card.id + ':run')) card.process.open = choices.get(card.id + ':run');
658
+ if (notStarted) card.process.open = false;
659
+ else if (choices.has(card.id + ':run')) card.process.open = choices.get(card.id + ':run');
566
660
  // A live run starts collapsed onto its peek window; problems open it.
567
- else card.process.open = abnormal;
661
+ else card.process.open = abnormal && !outcome;
568
662
  card.events.dataset.expanded = String(card.process.open);
569
663
  card.summary.setAttribute('aria-expanded', String(card.process.open));
570
664
  reconcile(card.events, result.blocks, card.rows, block => buildBlock(block, card),
@@ -575,6 +669,7 @@
575
669
  else if (!choices.has(card.id + ':setup')) card.setup.open = false;
576
670
  if (!card.process.open) card.setup.hidden = true;
577
671
  renderPeek(card, result, run, active);
672
+ renderCompaction(card, run, result);
578
673
  card.noticeCount = result.notices.length;
579
674
  const latestNotice = result.notices.at(-1);
580
675
  card.attention.hidden = card.process.open || !latestNotice;
@@ -589,12 +684,13 @@
589
684
  || block.meta?.state === 'running');
590
685
  // The presence header already narrates the live state.
591
686
  card.busy.hidden = !active || hasActive || Boolean(presence);
592
- text(card.busy, result.stage === 'local_completed'
687
+ text(card.busy, result.compacting ? tx('Compacting context…', '正在压缩上下文…') : result.stage === 'local_completed'
593
688
  ? (run.mode === 'ask' ? tx('Preparing the answer…', '正在整理回答…') : tx('Preparing writeback and verification…', '正在准备写回与验证…'))
594
689
  : result.stage === 'preparing' ? tx('Preparing project context…', '正在准备项目上下文…') : tx('Working…', '正在处理…'));
595
690
  }
596
691
  // Live header: long phases are named; otherwise a rarely-changing word. Tool steps stay in the round list.
597
692
  function runningLabel(result, run) {
693
+ if (result.compacting) return { kind: 'stage', text: tx('Compacting context', '正在压缩上下文') };
598
694
  if (result.stage === 'sync') return { kind: 'stage', text: tx('Syncing changes to Overleaf', '正在同步改动到 Overleaf') };
599
695
  // Before the model's first response the header names the real start-up step, not a playful word.
600
696
  if (result.setup && !result.setup.done) return { kind: 'stage', text: setupLiveLabel(result.setup) };
@@ -608,6 +704,11 @@
608
704
  function paintPresence(card, run, result, active) {
609
705
  const status = card.root.querySelector('[data-run-status]');
610
706
  if (!presence || !status) return;
707
+ const notStarted = getNotStartedRun?.(run);
708
+ if (notStarted) {
709
+ presence.paintSettled(status, { kind: notStarted.cancelled ? 'not-started-cancelled' : 'not-started', text: notStarted.status });
710
+ return;
711
+ }
611
712
  // History cards arrive with plain status text; keep it for failure and fallback labels.
612
713
  if (!status.dataset.presence && !status.dataset.presenceText) status.dataset.presenceText = status.textContent;
613
714
  const started = Date.parse(run.startedAt || '');
@@ -621,6 +722,12 @@
621
722
  elapsedMs: Number.isFinite(started) && Number.isFinite(finished) ? Math.max(0, finished - started) : null,
622
723
  files: result.fileCount, filesClipped: result.clipped === true, added: 0, removed: 0, steps: 0, compiled: false,
623
724
  projectId: getProjectId?.() || '', animate: card.animateNext === true };
725
+ const outcome = getFinishedRun?.(run);
726
+ if (outcome?.status) {
727
+ facts.kind = outcome.kind;
728
+ facts.text = outcome.status;
729
+ }
730
+ if (outcome?.kind === 'compile-errors') facts.compileChip = outcome.compileChip;
624
731
  card.animateNext = false;
625
732
  for (const block of result.blocks) {
626
733
  if (['message', 'guidance', 'thought'].includes(block.kind)) continue;
@@ -631,6 +738,7 @@
631
738
  }
632
739
  if (block.kind === 'compile' && block.meta?.state === 'completed') facts.compiled = true;
633
740
  }
741
+ if (facts.compileChip) facts.compiled = false;
634
742
  presence.paintSettled(status, facts);
635
743
  }
636
744
  function update(view, incoming) {
@@ -4,6 +4,180 @@
4
4
  })(typeof window !== 'undefined' ? window : globalThis, function (FailureReasons, I18n, AgentTranscript) {
5
5
  'use strict';
6
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
+
7
181
  const FLAT_REPORT_STATUS_SECTIONS = [
8
182
  { key: 'saveState', prefixes: ['Save:', '保存:'], en: 'Save', zh: '保存' },
9
183
  { key: 'unchangedReason', prefixes: ['Why nothing changed:', '未修改原因:'], en: 'Why nothing changed', zh: '未修改原因' },
@@ -19,7 +193,8 @@
19
193
  ['This run was Ask mode.', '这轮是只问不改。'],
20
194
  ['You cancelled this change.', '你取消了这轮修改。'],
21
195
  ['No file changes need to sync back to Overleaf.', '没有产生需要同步回 Overleaf 的文件改动。'],
22
- ['No approved file changes remain 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.', '发送前发现了敏感内容。']
23
198
  ],
24
199
  undo: [['this run has no reversible writes', '本轮没有可撤销的写入']],
25
200
  saveState: [['Saved', '已确认保存'],
@@ -230,6 +405,8 @@
230
405
  '本轮结束时记录了以下文件夹,当前状态尚未重新核验:') + paths.join(', ') });
231
406
  }
232
407
  const retentionOmitted = hasOmittedRecoveryRetention(run);
408
+ const undoUnavailable = !undone && run.trackedChangeStatus !== 'accepted'
409
+ ? projectRunSettlement?.(run).undoUnavailable || [] : [];
233
410
  const inFlight = trackedChangeInFlight?.get(run.id);
234
411
  const tracked = isTrackedChangeLifecycleRun(run);
235
412
  const pendingFileUndo = tracked && run.trackedChangeStatus === 'needs_review' && run.undoOperations?.some(operation => operation?.undoCreatedFile?.v === 1 || operation?.type === 'create');
@@ -249,7 +426,7 @@
249
426
  ? tx(pendingFileUndo ? 'Undo is incomplete; retry the remaining files.' : 'Undo is incomplete; remaining changes can still be undone.', pendingFileUndo ? '撤销未完成,可重试剩余文件。' : '撤销未完成,剩余修改仍可撤销。')
250
427
  : tx('Undo is incomplete.', '撤销未完成。');
251
428
  }
252
- if (!undo && !run.saveCheck && !run.saveConfirmedAt && !retentionOmitted) return rows;
429
+ if (!undo && !run.saveCheck && !run.saveConfirmedAt && !retentionOmitted && !undoUnavailable.length) return rows;
253
430
  const projected = rows.map(row => row?.key === 'undo' && undo
254
431
  ? { ...row, label: tx('Undo', '撤销'), value: undo }
255
432
  : row?.key === 'saveState' && (run.saveCheck || run.saveConfirmedAt)
@@ -270,6 +447,13 @@
270
447
  value: directory && (run.recoveryRetention.coreRetained === true || undone || run.trackedChangeStatus === 'accepted')
271
448
  ? directory : [recovery, directory].filter(Boolean).join(' ') });
272
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
+ }
273
457
  return projected;
274
458
  }
275
459
 
@@ -311,6 +495,7 @@
311
495
  const reason = find('unchangedReason');
312
496
  const undone = run?.undoStatus === 'applied' || run?.trackedChangeStatus === 'rejected';
313
497
  const retentionOmitted = hasOmittedRecoveryRetention(run);
498
+ const undoUnavailable = rows.some(row => row?.undoUnavailable === true);
314
499
  const savePending = !undone && (Boolean(run?.saveCheck) || (!run?.saveConfirmedAt && /^(?:Pending|待确认)/.test(find('saveState'))));
315
500
  const pendingFileUndo = run?.trackedChangeStatus === 'needs_review'
316
501
  && run.undoOperations?.some(operation => operation?.undoCreatedFile?.v === 1 || operation?.type === 'create');
@@ -327,18 +512,21 @@
327
512
  : pendingFileUndo || run?.undoStatus === 'partial'
328
513
  ? (hasUndoProgress(run) ? tx('Partly undone', '已撤销部分修改') : tx('Undo incomplete', '撤销未完成'))
329
514
  : undoable ? tx(`${undoable} undoable`, `可撤销 ${undoable} 项`)
330
- : failed || retentionOmitted ? '' : tx('Nothing to undo', '无可撤销写入');
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', '无可撤销写入');
331
519
  if (undo) facts.push({ key: 'undo', text: undo });
332
520
  if (retentionOmitted) facts.push({ key: 'recoveryRetention', tone: 'warn',
333
521
  text: run.recoveryRetention.coreRetained === true
334
522
  ? tx('Folder observations not retained after reload', '刷新后文件夹观测记录未保留')
335
523
  : tx('Recovery data not retained after reload', '刷新后恢复材料未保留') });
336
524
  if (savePending) facts.push({ key: 'save', tone: 'warn', text: tx('Save pending', '保存待确认') });
337
- const attention = failed || skipped > 0 || retentionOmitted;
525
+ const attention = failed || skipped > 0 || retentionOmitted || undoUnavailable;
338
526
  const warnKeys = new Set([...(failed || skipped > 0 ? ['writeResult', 'nextStep'] : []),
339
- ...(retentionOmitted ? ['recoveryRetention'] : []), ...(savePending ? ['saveState'] : [])]);
527
+ ...(retentionOmitted || undoUnavailable ? ['recoveryRetention'] : []), ...(savePending ? ['saveState'] : [])]);
340
528
  return { facts, open: attention, warnKeys };
341
529
  }
342
530
 
343
- return { splitFlatCompletionReport, projectCompletionBody, projectCompletionMeta, summarizeCompletionMeta };
531
+ return { splitFlatCompletionReport, projectNotStartedRun, projectFinishedRun, projectCompletionConclusion, projectCompletionBody, projectCompletionMeta, summarizeCompletionMeta };
344
532
  });
@@ -23,6 +23,7 @@
23
23
  function buildCodexRunParams({
24
24
  currentProjectId,
25
25
  state = {},
26
+ locale = state.locale,
26
27
  task,
27
28
  project,
28
29
  useExistingMirror,
@@ -62,7 +63,7 @@
62
63
  model: state.model,
63
64
  reasoningEffort: state.reasoningEffort,
64
65
  speedTier: state.speedTier,
65
- locale: state.locale === 'zh' ? 'zh' : 'en',
66
+ locale: locale === 'zh' ? 'zh' : 'en',
66
67
  session: state.session,
67
68
  threadId: codexThreadId || undefined,
68
69
  customInstructions: normalizedCustomInstructions || undefined,
@@ -26,7 +26,7 @@
26
26
  document: documentRef = globalThis.document } = {}) {
27
27
  const notices = new WeakMap();
28
28
 
29
- function append(report, event = {}, run = {}) {
29
+ function append(report, event = {}, run = {}, outcomeTarget = null) {
30
30
  const projection = typeof projectRunSettlement === 'function' && run
31
31
  ? projectRunSettlement(run) : null;
32
32
  const failure = projection?.primaryFailure || event.failure;
@@ -38,6 +38,13 @@
38
38
  const partial = facts.evidence?.applied === 'partial' || run?.partialWriteback === true;
39
39
  if (!failure && !unconfirmed && !partial && report.dataset.status !== 'failed') return;
40
40
 
41
+ // A quiet outcome replaces the notice's presentation, not its recovery
42
+ // selection or precedence. Keep the same context for prepareRecovery.
43
+ if (outcomeTarget) {
44
+ notices.set(report, { notice: outcomeTarget, unconfirmed, failure, file: safeProjectPath(failure?.file) });
45
+ return;
46
+ }
47
+
41
48
  const notice = documentRef.createElement('aside');
42
49
  notice.className = 'run-feedback';
43
50
  notice.dataset.tone = unconfirmed || partial || failure?.terminalState === 'degraded'
@@ -39,6 +39,10 @@
39
39
  const MARK_STYLE = '--mark-leaf-origin:70.6% 41.6%;--mark-cloud-origin:44.3% 55%';
40
40
  const ICONS = {
41
41
  alert: '<circle cx="8" cy="8" r="6"/><path d="M8 4.5v4 M8 11h.01"/>',
42
+ minus: '<circle cx="8" cy="8" r="6"/><path d="M5 8h6"/>',
43
+ half: '<circle cx="6" cy="6" r="5"/><path d="M6 1a5 5 0 0 0 0 10z" fill="currentColor" stroke="none"/>',
44
+ written: '<path d="m2.4 6.3 2.4 2.4 4.8-5.2" stroke-width="1.5"/>',
45
+ skipped: '<path d="m3 3 6 6M9 3 3 9" stroke-width="1.5"/>',
42
46
  check: '<path d="m3.5 8 3 3 6-6"/>',
43
47
  file: '<path d="M9 2H4v12h8V5L9 2Z M9 2v4h3 M6 9h4 M6 11h3"/>',
44
48
  refresh: '<path d="M4.2 5.3A5 5 0 1 1 3.6 10"/><path d="M4.2 2.2v3.1H1.1"/>'
@@ -49,7 +53,8 @@
49
53
  })[char]);
50
54
 
51
55
  function icon(name, className = 'codex-glyph') {
52
- return '<svg class="' + className + '" viewBox="0 0 16 16" aria-hidden="true" focusable="false">'
56
+ const size = ['half', 'written', 'skipped'].includes(name) ? 12 : 16;
57
+ return '<svg class="' + className + '" viewBox="0 0 ' + size + ' ' + size + '" aria-hidden="true" focusable="false">'
53
58
  + (ICONS[name] || ICONS.check) + '</svg>';
54
59
  }
55
60
 
@@ -103,14 +108,16 @@
103
108
  const files = tx(facts.files + (facts.files === 1 ? ' file' : ' files'), facts.files + ' 个文件');
104
109
  // A clipped history only proves the files it recorded.
105
110
  parts.push('<span>' + escapeHtml(facts.filesClipped ? tx(files + ' recorded', '已记录 ' + files) : files) + '</span>');
106
- if (facts.added || facts.removed) {
107
- parts.push('<span><span class="run-summary-add">+' + Number(facts.added || 0) + '</span> <span class="run-summary-del">−'
108
- + Number(facts.removed || 0) + '</span></span>');
109
- }
111
+ // Show only nonzero sides; a red "−0" reads as a problem that isn't there.
112
+ const counts = [];
113
+ if (Number(facts.added) > 0) counts.push('<span class="run-summary-add">+' + Number(facts.added) + '</span>');
114
+ if (Number(facts.removed) > 0) counts.push('<span class="run-summary-del">−' + Number(facts.removed) + '</span>');
115
+ if (counts.length) parts.push('<span>' + counts.join(' ') + '</span>');
110
116
  } else if (facts.steps > 0) {
111
- parts.push('<span>' + escapeHtml(tx(facts.steps + ' steps', facts.steps + ' 步')) + '</span>');
117
+ parts.push('<span>' + escapeHtml(tx(facts.steps + (facts.steps === 1 ? ' step' : ' steps'), facts.steps + ' 步')) + '</span>');
112
118
  }
113
119
  if (facts.compiled) parts.push('<span>' + escapeHtml(tx('compiled', '已编译')) + '</span>');
120
+ if (facts.compileChip) parts.push('<span class="run-summary-compile-chip">' + escapeHtml(facts.compileChip) + '</span>');
114
121
  if (facts.firstCompile) {
115
122
  parts.push('<span class="run-summary-qed">' + escapeHtml(tx('Q.E.D. — first clean compile', 'Q.E.D. · 首次编译通过')) + '</span>');
116
123
  }
@@ -253,8 +260,11 @@
253
260
  el.dataset.presence = facts.kind || 'plain';
254
261
  if (facts.kind === 'done' || facts.kind === 'stopped') {
255
262
  el.innerHTML = settledHtml({ ...facts, firstCompile }, tx, formatElapsed);
256
- } else if (facts.kind === 'failed') {
257
- el.innerHTML = icon('alert') + '<span>' + escapeHtml(facts.text) + '</span>';
263
+ } else if (['failed', 'not-started', 'not-started-cancelled'].includes(facts.kind)) {
264
+ el.innerHTML = icon(facts.kind === 'not-started-cancelled' ? 'minus' : 'alert') + '<span>' + escapeHtml(facts.text) + '</span>';
265
+ } else if (['didnt-finish', 'partly-written', 'not-written', 'check-overleaf'].includes(facts.kind)) {
266
+ el.innerHTML = icon(facts.kind === 'check-overleaf' ? 'alert' : facts.kind === 'partly-written' ? 'half' : 'minus') + '<span>' + escapeHtml(facts.text) + '</span>'
267
+ + (Number.isFinite(facts.elapsedMs) ? '<span class="run-summary-sep">·</span><span>' + escapeHtml(formatElapsed(facts.elapsedMs)) + '</span>' : '');
258
268
  } else {
259
269
  el.textContent = facts.text || '';
260
270
  }
@@ -444,12 +444,16 @@
444
444
  }
445
445
 
446
446
  async function startQueuedInput(item, sessionId) {
447
- if (options.canStart?.(sessionId) === false || options.getCurrentRun?.()) {
448
- const error = new Error('Queued input cannot start in the current session.');
449
- error.code = 'queue_session_not_active';
450
- throw error;
451
- }
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();
452
455
  await options.applyQueuedInput?.(item);
456
+ assertCanStart();
453
457
  await options.run?.(item);
454
458
  }
455
459