codex-overleaf-link 1.8.6 → 1.9.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 (72) hide show
  1. package/README.md +45 -21
  2. package/bin/codex-overleaf-link.mjs +58 -0
  3. package/extension/assets/icons/codex-overleaf-dialog-icon.png +0 -0
  4. package/extension/assets/icons/codex-overleaf-icon.png +0 -0
  5. package/extension/assets/icons/icon128.png +0 -0
  6. package/extension/assets/icons/icon16.png +0 -0
  7. package/extension/assets/icons/icon32.png +0 -0
  8. package/extension/assets/icons/icon48.png +0 -0
  9. package/extension/bootstrap/background.js +404 -0
  10. package/extension/bootstrap/manifest.template.json +51 -0
  11. package/extension/bootstrap/popup.css +26 -0
  12. package/extension/bootstrap/popup.html +38 -0
  13. package/extension/bootstrap/popup.js +44 -0
  14. package/extension/bootstrap/runtimeContext.js +2 -0
  15. package/extension/runtime-manifest.json +60 -0
  16. package/extension/src/background.js +532 -0
  17. package/extension/src/content/applyResultFormatters.js +387 -0
  18. package/extension/src/content/composerAttachments.js +522 -0
  19. package/extension/src/content/composerPanel.js +254 -0
  20. package/extension/src/content/contentRuntime.js +8895 -0
  21. package/extension/src/content/contextTray.js +686 -0
  22. package/extension/src/content/diagnosticsController.js +659 -0
  23. package/extension/src/content/diagnosticsPanel.js +342 -0
  24. package/extension/src/content/diffReviewPanel.js +797 -0
  25. package/extension/src/content/localSkillsPanel.js +280 -0
  26. package/extension/src/content/markdownText.js +606 -0
  27. package/extension/src/content/mirrorHealth.js +92 -0
  28. package/extension/src/content/modelPicker.js +489 -0
  29. package/extension/src/content/nativeChannel.js +62 -0
  30. package/extension/src/content/otWarmMirror.js +964 -0
  31. package/extension/src/content/otWarmMirrorController.js +174 -0
  32. package/extension/src/content/panelMaintenance.js +367 -0
  33. package/extension/src/content/panelRenderer.js +253 -0
  34. package/extension/src/content/recentProjects.js +1063 -0
  35. package/extension/src/content/reviewHunks.js +247 -0
  36. package/extension/src/content/runController.js +242 -0
  37. package/extension/src/content/runTimelineView.js +1126 -0
  38. package/extension/src/content/sessionManager.js +487 -0
  39. package/extension/src/content/sessionPanel.js +263 -0
  40. package/extension/src/content/settingsPanel.js +527 -0
  41. package/extension/src/content/themeController.js +91 -0
  42. package/extension/src/content/updateIdle.js +67 -0
  43. package/extension/src/content/writebackController.js +300 -0
  44. package/extension/src/content/writebackOrchestrator.js +850 -0
  45. package/extension/src/contentScript.js +8 -0
  46. package/extension/src/page/compileBridge.js +460 -0
  47. package/extension/src/page/overleafCapabilities.js +100 -0
  48. package/extension/src/page/overleafEditor.js +282 -0
  49. package/extension/src/page/overleafProjectSnapshot.js +234 -0
  50. package/extension/src/page/overleafRealtimeObserver.js +491 -0
  51. package/extension/src/page/pageBridgeCapability.js +71 -0
  52. package/extension/src/page/snapshotRouter.js +948 -0
  53. package/extension/src/page/trackedChangesLifecycle.js +1506 -0
  54. package/extension/src/page/treeOperations.js +1584 -0
  55. package/extension/src/page/writeGuard.js +142 -0
  56. package/extension/src/page/writebackRouter.js +1934 -0
  57. package/extension/src/pageBridge.js +2224 -0
  58. package/extension/src/popup.js +247 -0
  59. package/extension/src/shared/compatibility.js +1 -1
  60. package/extension/styles/panel.css +4048 -0
  61. package/native-host/src/index.js +9 -2
  62. package/native-host/src/managedInstall.js +393 -0
  63. package/native-host/src/managedLauncherRuntime.js +71 -0
  64. package/native-host/src/nativeHostPlatform.js +16 -0
  65. package/native-host/src/runtimeInstaller.js +6 -1
  66. package/native-host/src/taskRunnerRuntime.js +8 -0
  67. package/native-host/src/updateArchive.js +253 -0
  68. package/native-host/src/updateManager.js +548 -0
  69. package/native-host/src/updateTrust.js +156 -0
  70. package/package.json +11 -2
  71. package/scripts/install-managed.mjs +64 -0
  72. package/scripts/uninstall-managed.mjs +48 -0
@@ -0,0 +1,1126 @@
1
+ (function initCodexOverleafRunTimelineView() {
2
+ 'use strict';
3
+
4
+ // Run-timeline view — the timeline render pipeline carved out of
5
+ // contentRuntime.js (v1.4.6 structural-debt phase 2): the scroll engine +
6
+ // jump-to-latest button, the live-elapsed tick and collapsed-header summary,
7
+ // run-card / stream-event / activity rendering, the completion report, and
8
+ // the run-card Undo/Accept controls. Code moved verbatim (original
9
+ // indentation kept); runtime collaborators are factory-injected and mutable
10
+ // runtime state (panel, panel state, the live run view) is read through lazy
11
+ // getters. The view-local scroll/timer state below moved here with the code
12
+ // that owns it.
13
+ function create(deps = {}) {
14
+ const {
15
+ tr,
16
+ tx,
17
+ getLocale,
18
+ formatElapsed,
19
+ findRunRecord,
20
+ forceCancelStuckTaskForCurrentProject,
21
+ formatEventDetail,
22
+ formatEventTime,
23
+ renderAttachmentPreviewList,
24
+ formatModeLabel,
25
+ undoRun,
26
+ acceptRun,
27
+ getRunUndoCount,
28
+ cssEscape,
29
+ renderMarkdownInlineText,
30
+ renderMarkdownBlockText,
31
+ sanitizeAssistantVisibleText,
32
+ sanitizeAssistantVisibleValue,
33
+ buildMarkdownInlineNodes,
34
+ getPanel,
35
+ getState,
36
+ getCurrentRunView,
37
+ refillComposerForRetry,
38
+ showNativeSetupGuidance,
39
+ openProjectFileForFailure,
40
+ openStorageSettings,
41
+ trackedChangeInFlight
42
+ } = deps;
43
+
44
+ let logAutoFollow = true;
45
+ let runSearchQuery = '';
46
+ let userScrollIntentUntil = 0;
47
+ // Scroll engine: a single rAF coalesces a burst of scroll requests into one
48
+ // write per frame (streaming can fire ~25/sec); `scrollLogPendingForce`
49
+ // survives that coalesce so a forced scroll is never lost. `unreadSinceDetach`
50
+ // drives the floating "jump to latest" button's counter.
51
+ let scrollLogRafId = 0;
52
+ let scrollLogPendingForce = false;
53
+ let jumpToLatestButton = null;
54
+ let unreadSinceDetach = 0;
55
+ // Live-elapsed tick for the sticky run-process header. Without it the header
56
+ // reads a static "Processing…" and the user can't tell a working run from a
57
+ // hung one — the single highest-value streaming signal per competitor UX.
58
+ let runElapsedTimer = null;
59
+
60
+ // Re-arm auto-follow (called by the runtime when a new run starts, so the
61
+ // log snaps back to following the live stream).
62
+ function resetAutoFollow() {
63
+ logAutoFollow = true;
64
+ userScrollIntentUntil = 0;
65
+ }
66
+
67
+ function bindLogAutoFollow() {
68
+ const scroller = getLogScrollContainer();
69
+ if (!scroller || scroller.dataset.autoFollowBound === 'true') {
70
+ return;
71
+ }
72
+ scroller.dataset.autoFollowBound = 'true';
73
+ scroller.addEventListener('wheel', markUserScrollIntent, { passive: true });
74
+ scroller.addEventListener('touchmove', markUserScrollIntent, { passive: true });
75
+ scroller.addEventListener('pointerdown', markUserScrollIntent, { passive: true });
76
+ scroller.addEventListener('keydown', event => {
77
+ if (['ArrowUp', 'ArrowDown', 'PageUp', 'PageDown', 'Home', 'End', ' '].includes(event.key)) {
78
+ markUserScrollIntent();
79
+ }
80
+ });
81
+ scroller.addEventListener('scroll', () => {
82
+ if (Date.now() <= userScrollIntentUntil) {
83
+ logAutoFollow = isLogNearBottom(scroller);
84
+ } else if (isLogNearBottom(scroller)) {
85
+ logAutoFollow = true;
86
+ }
87
+ // Reveal / hide the floating "jump to latest" button the instant the
88
+ // user detaches from or re-reaches the bottom.
89
+ updateJumpToLatestButton(scroller);
90
+ }, { passive: true });
91
+ }
92
+
93
+ // Floating "↓ latest" affordance. Lives in the non-scrolling thread section
94
+ // (sibling layer above the scroll container) so it stays pinned while the
95
+ // user reads backscroll. Shown only while detached from the bottom.
96
+ function ensureJumpToLatestButton() {
97
+ if (jumpToLatestButton && jumpToLatestButton.isConnected) {
98
+ return jumpToLatestButton;
99
+ }
100
+ const scroller = getLogScrollContainer();
101
+ const host = scroller?.closest?.('.codex-thread-section') || scroller?.parentElement;
102
+ if (!host) {
103
+ return null;
104
+ }
105
+ const button = document.createElement('button');
106
+ button.type = 'button';
107
+ button.className = 'tl-jump-latest';
108
+ button.hidden = true;
109
+ button.addEventListener('click', event => {
110
+ event.stopPropagation();
111
+ unreadSinceDetach = 0;
112
+ scrollLogToBottom({ force: true });
113
+ });
114
+ host.append(button);
115
+ jumpToLatestButton = button;
116
+ return button;
117
+ }
118
+
119
+ function updateJumpToLatestButton(scroller) {
120
+ const el = scroller || getLogScrollContainer();
121
+ if (!el) {
122
+ return;
123
+ }
124
+ const button = ensureJumpToLatestButton();
125
+ if (!button) {
126
+ return;
127
+ }
128
+ if (isLogNearBottom(el)) {
129
+ unreadSinceDetach = 0;
130
+ button.hidden = true;
131
+ return;
132
+ }
133
+ button.hidden = false;
134
+ button.textContent = unreadSinceDetach > 0
135
+ ? tx(`↓ Latest · ${unreadSinceDetach}`, `↓ 最新 · ${unreadSinceDetach}`)
136
+ : tx('↓ Latest', '↓ 最新');
137
+ button.setAttribute('aria-label', unreadSinceDetach > 0
138
+ ? tx(`Jump to latest, ${unreadSinceDetach} new steps`, `跳到最新,${unreadSinceDetach} 个新步骤`)
139
+ : tx('Jump to latest', '跳到最新'));
140
+ }
141
+
142
+ // Called by the event-append path so the unread counter only counts discrete
143
+ // steps (activity / report) while the user is scrolled up — streaming deltas
144
+ // that update an existing line in place do not inflate the count.
145
+ function bumpUnreadIfDetached() {
146
+ const el = getLogScrollContainer();
147
+ if (el && !isLogNearBottom(el)) {
148
+ unreadSinceDetach += 1;
149
+ }
150
+ }
151
+
152
+ function getLogScrollContainer() {
153
+ return getPanel()?.querySelector('[data-log]') || getPanel()?.querySelector('[data-main]');
154
+ }
155
+
156
+ function markUserScrollIntent() {
157
+ userScrollIntentUntil = Date.now() + 1200;
158
+ }
159
+
160
+ function isLogNearBottom(log) {
161
+ if (!log) {
162
+ return true;
163
+ }
164
+ return log.scrollHeight - log.scrollTop - log.clientHeight < 48;
165
+ }
166
+
167
+ function scrollLogToBottom(options = {}) {
168
+ const scroller = getLogScrollContainer();
169
+ if (!scroller) {
170
+ return;
171
+ }
172
+ const force = options.force === true;
173
+ if (force) {
174
+ // A forced scroll (user submitted a run, clicked "jump to latest", etc.)
175
+ // re-arms auto-follow and clears any stale user-scroll intent.
176
+ logAutoFollow = true;
177
+ userScrollIntentUntil = 0;
178
+ unreadSinceDetach = 0;
179
+ scrollLogPendingForce = true;
180
+ } else if (!(logAutoFollow || isLogNearBottom(scroller))) {
181
+ // The user has scrolled up: do not yank them. Just keep the jump button
182
+ // state current.
183
+ updateJumpToLatestButton(scroller);
184
+ return;
185
+ }
186
+
187
+ const writeNow = () => {
188
+ const el = getLogScrollContainer();
189
+ if (!el) {
190
+ return;
191
+ }
192
+ // Re-check intent AT PAINT TIME: a user who flicked up between schedule
193
+ // and paint must not be snapped back down (closes the one-frame fight).
194
+ if (scrollLogPendingForce || logAutoFollow || isLogNearBottom(el)) {
195
+ setLogScrollPosition(el);
196
+ }
197
+ scrollLogPendingForce = false;
198
+ updateJumpToLatestButton(el);
199
+ };
200
+
201
+ if (typeof window.requestAnimationFrame !== 'function') {
202
+ writeNow();
203
+ return;
204
+ }
205
+ // Coalesce a burst into one write per frame (no more double reflow).
206
+ if (scrollLogRafId) {
207
+ return;
208
+ }
209
+ scrollLogRafId = window.requestAnimationFrame(() => {
210
+ scrollLogRafId = 0;
211
+ writeNow();
212
+ });
213
+ }
214
+ function setLogScrollPosition(scroller) {
215
+ scroller.scrollTop = scroller.scrollHeight;
216
+ }
217
+ function collapseRunProcess(view, statusText) {
218
+ const runProcess = view?.runProcess || view?.root?.querySelector('[data-run-process]');
219
+ if (runProcess) {
220
+ runProcess.open = false;
221
+ }
222
+ const statusEl = view?.status || view?.root?.querySelector('[data-run-status]');
223
+ if (statusEl) {
224
+ // Append the step count to the collapsed header ("Processed 18s · 6 steps")
225
+ // so the user sees how much work the run did without expanding it.
226
+ const stepCount = countRunActivitySteps(view);
227
+ statusEl.textContent = stepCount > 0
228
+ ? `${statusText} · ${tx(`${stepCount} steps`, `${stepCount} 步`)}`
229
+ : statusText;
230
+ }
231
+ }
232
+
233
+ function countRunActivitySteps(view) {
234
+ const record = view?.recordId ? findRunRecord(view.recordId, view.sessionId) : null;
235
+ if (!Array.isArray(record?.events)) {
236
+ return 0;
237
+ }
238
+ return record.events.filter(event => (event.kind || 'activity') === 'activity').length;
239
+ }
240
+
241
+ // The sticky run-process header shows a live "Processing… {elapsed}" while a
242
+ // run is in flight so the user can distinguish a working run from a hung one.
243
+ function startRunElapsedTick() {
244
+ stopRunElapsedTick();
245
+ if (typeof window.setInterval !== 'function') {
246
+ return;
247
+ }
248
+ runElapsedTimer = window.setInterval(() => {
249
+ if (!getCurrentRunView()) {
250
+ stopRunElapsedTick();
251
+ return;
252
+ }
253
+ const statusEl = getCurrentRunView().status
254
+ || getCurrentRunView().root?.querySelector('[data-run-status]');
255
+ if (statusEl) {
256
+ statusEl.textContent = tr('processing', {
257
+ elapsed: formatElapsed(Date.now() - getCurrentRunView().startedAt)
258
+ });
259
+ }
260
+ }, 1000);
261
+ }
262
+
263
+ function stopRunElapsedTick() {
264
+ if (runElapsedTimer && typeof window.clearInterval === 'function') {
265
+ window.clearInterval(runElapsedTimer);
266
+ }
267
+ runElapsedTimer = null;
268
+ }
269
+
270
+ function formatProcessedSummary(status, elapsedMs) {
271
+ const elapsed = formatElapsed(elapsedMs);
272
+ if (status === 'failed') {
273
+ return tr('processedFailed', { elapsed });
274
+ }
275
+ if (status === 'running') {
276
+ return tr('processing', { elapsed });
277
+ }
278
+ return tr('processed', { elapsed });
279
+ }
280
+ function renderRunHistory() {
281
+ const log = getPanel()?.querySelector('[data-log]');
282
+ if (!log) {
283
+ return;
284
+ }
285
+ log.replaceChildren();
286
+ if (!getState().runs?.length) {
287
+ const empty = document.createElement('div');
288
+ empty.className = 'empty-runs';
289
+ const icon = document.createElement('img');
290
+ icon.className = 'codex-empty-icon';
291
+ icon.alt = '';
292
+ icon.setAttribute('aria-hidden', 'true');
293
+ icon.src = chrome.runtime.getURL('assets/icons/codex-overleaf-icon.png');
294
+ const label = document.createElement('div');
295
+ label.textContent = tr('emptyRunLabel');
296
+ const hint = document.createElement('div');
297
+ hint.className = 'empty-runs-hint';
298
+ hint.textContent = tr('emptyRunsHint');
299
+ empty.append(icon, label, hint);
300
+ log.append(empty);
301
+ return;
302
+ }
303
+ const runs = getState().runs;
304
+ if (runs.length >= 20) {
305
+ const truncated = document.createElement('div');
306
+ truncated.className = 'run-history-truncated';
307
+ truncated.textContent = tr('runsTruncatedNote');
308
+ log.append(truncated);
309
+ }
310
+ // In-session search (v1.8.0): filter run cards by task or report text.
311
+ // Rendered with the same >= 3 threshold as the turn navigator; filtering
312
+ // is pure display (hidden attribute), state is kept across re-renders.
313
+ if (runs.length < 3 && runSearchQuery) {
314
+ // No search box is rendered below this threshold — a stale query from a
315
+ // bigger session would silently blank this log with no way to clear it.
316
+ runSearchQuery = '';
317
+ }
318
+ if (runs.length >= 3) {
319
+ const search = document.createElement('input');
320
+ search.type = 'search';
321
+ search.className = 'run-search';
322
+ search.setAttribute('data-run-search', '');
323
+ search.placeholder = tr('runSearchPlaceholder');
324
+ search.setAttribute('aria-label', tr('runSearchPlaceholder'));
325
+ search.value = runSearchQuery;
326
+ search.addEventListener('input', () => {
327
+ runSearchQuery = search.value;
328
+ applyRunSearchFilter(log);
329
+ });
330
+ log.append(search);
331
+ }
332
+ // Turn navigation (v1.7.5): once the log holds enough turns that
333
+ // scroll-hunting hurts, offer a jump-to-turn dropdown pinned above them.
334
+ if (runs.length >= 3) {
335
+ const nav = document.createElement('select');
336
+ nav.className = 'run-turn-nav';
337
+ nav.setAttribute('data-run-turn-nav', '');
338
+ nav.setAttribute('aria-label', tr('runTurnNavLabel'));
339
+ const placeholder = document.createElement('option');
340
+ placeholder.value = '';
341
+ placeholder.textContent = tr('runTurnNavLabel');
342
+ nav.append(placeholder);
343
+ runs.forEach((run, index) => {
344
+ const option = document.createElement('option');
345
+ option.value = run.id;
346
+ option.textContent = `#${index + 1} ${String(run.task || '').replace(/\s+/g, ' ').slice(0, 48)}`;
347
+ nav.append(option);
348
+ });
349
+ nav.addEventListener('change', () => {
350
+ if (!nav.value) {
351
+ return;
352
+ }
353
+ const card = log.querySelector(`[data-run-id="${cssEscape(nav.value)}"]`);
354
+ card?.scrollIntoView({ block: 'start' });
355
+ nav.value = '';
356
+ });
357
+ log.append(nav);
358
+ }
359
+ for (const run of runs) {
360
+ log.append(renderRunCard(run));
361
+ }
362
+ if (runSearchQuery) {
363
+ applyRunSearchFilter(log);
364
+ }
365
+ scrollLogToBottom({ force: true });
366
+ }
367
+
368
+ // Case-insensitive substring match over the run's task text and its
369
+ // report/event narration. Empty query shows everything again.
370
+ function applyRunSearchFilter(log) {
371
+ const query = String(runSearchQuery || '').trim().toLowerCase();
372
+ const state = getState();
373
+ const matchingIds = new Set();
374
+ if (query) {
375
+ for (const run of state.runs || []) {
376
+ const haystack = [
377
+ run.task || '',
378
+ ...(run.events || []).map(event => `${event.title || ''} ${typeof event.detail === 'string' ? event.detail : ''}`)
379
+ ].join(' ').toLowerCase();
380
+ if (haystack.includes(query)) {
381
+ matchingIds.add(run.id);
382
+ }
383
+ }
384
+ }
385
+ for (const card of log.querySelectorAll('[data-run-id]')) {
386
+ card.hidden = Boolean(query) && !matchingIds.has(card.dataset.runId);
387
+ }
388
+ }
389
+
390
+ function renderRunCard(run) {
391
+ const root = document.createElement('section');
392
+ root.className = 'transcript-turn run-card';
393
+ root.dataset.status = run.status || 'completed';
394
+ root.dataset.runId = run.id;
395
+ root.title = [
396
+ `${tr('mode')}: ${formatModeLabel(run.mode)}`,
397
+ run.model,
398
+ run.reasoningEffort,
399
+ run.speedTier,
400
+ run.startedAt ? formatEventTime(run.startedAt) : ''
401
+ ].filter(Boolean).join(' · ');
402
+ root.innerHTML = `
403
+ <div class="transcript-turn-main">
404
+ <div class="run-attachments codex-attachment-preview-list" data-run-attachments hidden></div>
405
+ <div class="run-prompt" data-run-task></div>
406
+ <div class="run-turn-meta">
407
+ <button type="button" data-run-accept hidden title="Accept this run's tracked changes in Overleaf">Accept changes</button>
408
+ <button type="button" data-run-undo hidden title="Undo this run's writes to Overleaf">Undo</button>
409
+ </div>
410
+ <details class="run-process" data-run-process>
411
+ <summary data-run-process-summary>
412
+ <span class="run-status" data-run-status></span>
413
+ <span class="run-scan" aria-hidden="true"></span>
414
+ </summary>
415
+ <div class="run-activity-list" data-run-events></div>
416
+ </details>
417
+ <div class="run-report" data-run-report aria-live="polite" hidden></div>
418
+ </div>
419
+ `;
420
+
421
+ renderAttachmentPreviewList(run.attachments, root.querySelector('[data-run-attachments]'), { readonly: true });
422
+ root.querySelector('[data-run-task]').textContent = run.task || '';
423
+ root.querySelector('[data-run-status]').textContent = getRunStatusText(run);
424
+ const process = root.querySelector('[data-run-process]');
425
+ process.open = run.status === 'running';
426
+
427
+ const events = root.querySelector('[data-run-events]');
428
+ const report = root.querySelector('[data-run-report]');
429
+ if ((run.events || []).length >= 300) {
430
+ const truncated = document.createElement('div');
431
+ truncated.className = 'run-history-truncated';
432
+ truncated.textContent = tr('eventsTruncatedNote');
433
+ events.append(truncated);
434
+ }
435
+ for (const event of run.events || []) {
436
+ if (event.kind === 'report') {
437
+ report.hidden = false;
438
+ report.replaceChildren(renderCompletionReport(event, run));
439
+ } else if (event.kind === 'technical') {
440
+ continue;
441
+ } else if (event.kind === 'stream') {
442
+ upsertStreamEvent({ events }, event);
443
+ } else {
444
+ events.append(renderRunEvent(event));
445
+ }
446
+ }
447
+
448
+ configureAcceptButton(root, run);
449
+ configureUndoButton(root, run);
450
+ return root;
451
+ }
452
+
453
+ function getRunStatusText(run = {}) {
454
+ if (run.statusText) {
455
+ return run.statusText;
456
+ }
457
+ if (run.status === 'running') {
458
+ return tr('processing', { elapsed: '' }).trim();
459
+ }
460
+ if (run.startedAt && run.finishedAt) {
461
+ return formatProcessedSummary(run.status || 'completed', new Date(run.finishedAt).getTime() - new Date(run.startedAt).getTime());
462
+ }
463
+ return run.status === 'failed' ? tx('Failed', '处理失败') : tx('Done', '已处理');
464
+ }
465
+
466
+ function renderRunEvent(event) {
467
+ return renderActivityLine(sanitizeRunEventForRender(event));
468
+ }
469
+
470
+ function upsertStreamEvent(view, event) {
471
+ if (!view?.events) {
472
+ return;
473
+ }
474
+ const streamKey = event.streamKey || event.streamRole || 'codex-stream';
475
+ const selector = `[data-stream-key="${cssEscape(streamKey)}"]`;
476
+ const existing = view.events.querySelector(selector);
477
+ if (existing) {
478
+ existing.dataset.status = event.status || 'running';
479
+ existing.dataset.streamRole = event.streamRole || '';
480
+ const text = existing.querySelector('[data-stream-text]');
481
+ if (text) {
482
+ renderMarkdownInlineText(text, event.title || '');
483
+ }
484
+ return;
485
+ }
486
+ view.events.append(renderStreamEvent({ ...event, streamKey }));
487
+ }
488
+
489
+ function renderStreamEvent(input) {
490
+ const event = sanitizeRunEventForRender(input);
491
+ const row = document.createElement('div');
492
+ row.className = 'run-stream';
493
+ row.dataset.status = event.status || 'running';
494
+ row.dataset.streamKey = event.streamKey || event.streamRole || 'codex-stream';
495
+ row.dataset.streamRole = event.streamRole || '';
496
+
497
+ const text = document.createElement('div');
498
+ text.className = 'run-stream-text';
499
+ text.dataset.streamText = '';
500
+ renderMarkdownInlineText(text, event.title || '');
501
+
502
+ row.append(text);
503
+ return row;
504
+ }
505
+
506
+ function sanitizeRunEventForRender(event = {}) {
507
+ return {
508
+ ...event,
509
+ title: sanitizeAssistantVisibleText(event.title),
510
+ status: sanitizeAssistantVisibleText(event.status),
511
+ detail: sanitizeAssistantVisibleValue(event.detail),
512
+ technicalDetail: sanitizeAssistantVisibleValue(event.technicalDetail),
513
+ streamKey: sanitizeAssistantVisibleText(event.streamKey),
514
+ streamRole: sanitizeAssistantVisibleText(event.streamRole)
515
+ };
516
+ }
517
+ function renderActivityLine(input) {
518
+ const event = sanitizeRunEventForRender(input);
519
+ const row = document.createElement('div');
520
+ row.className = 'run-activity';
521
+ row.dataset.status = event.status || 'info';
522
+ row.dataset.kind = event.kind || 'activity';
523
+ if (event.subagent === true) {
524
+ row.dataset.subagent = 'true';
525
+ }
526
+ row.title = sanitizeAssistantVisibleText(buildActivityTooltip(event));
527
+
528
+ const marker = document.createElement('span');
529
+ marker.className = 'run-activity-dot';
530
+ marker.setAttribute('aria-hidden', 'true');
531
+
532
+ const label = document.createElement('span');
533
+ label.className = 'run-activity-title';
534
+ label.append(...buildMarkdownInlineNodes(event.title || 'Event'));
535
+
536
+ const time = document.createElement('time');
537
+ time.className = 'run-activity-time';
538
+ time.textContent = event.timestamp ? formatEventTime(event.timestamp) : '';
539
+
540
+ row.append(marker, label, time);
541
+ return row;
542
+ }
543
+
544
+ function hasNonEmptyDetail(value) {
545
+ if (value === undefined || value === null || value === '') {
546
+ return false;
547
+ }
548
+ if (Array.isArray(value)) {
549
+ return value.length > 0;
550
+ }
551
+ if (typeof value === 'object') {
552
+ return Object.keys(value).length > 0;
553
+ }
554
+ return true;
555
+ }
556
+
557
+ function renderTechnicalEvent(event) {
558
+ const block = document.createElement('section');
559
+ block.className = 'run-technical-event';
560
+ block.dataset.status = event.status || 'info';
561
+
562
+ const title = document.createElement('div');
563
+ title.className = 'run-technical-event-title';
564
+ title.textContent = event.title || tr('technicalDetails');
565
+
566
+ const body = document.createElement('pre');
567
+ body.textContent = formatEventDetail(buildTechnicalEventDetail(event));
568
+ block.append(title, body);
569
+ return block;
570
+ }
571
+
572
+ function buildTechnicalEventDetail(event) {
573
+ if (event?.kind === 'technical') {
574
+ return event.detail || {};
575
+ }
576
+
577
+ const detail = {
578
+ [tx('Step', '步骤')]: event?.title || '',
579
+ [tx('Status', '状态')]: event?.status || ''
580
+ };
581
+ if (event?.timestamp) {
582
+ detail[tx('Time', '时间')] = formatEventTime(event.timestamp);
583
+ }
584
+ if (hasNonEmptyDetail(event?.detail)) {
585
+ detail[tx('Content', '内容')] = event.detail;
586
+ }
587
+ if (hasNonEmptyDetail(event?.technicalDetail)) {
588
+ detail[tx('Raw event', '原始事件')] = event.technicalDetail;
589
+ }
590
+ return detail;
591
+ }
592
+
593
+ function buildActivityTooltip(event) {
594
+ return [
595
+ event?.title || '',
596
+ event?.timestamp ? formatEventTime(event.timestamp) : ''
597
+ ].filter(Boolean).join('\n');
598
+ }
599
+
600
+ // The trailing status sections emitted by formatHumanReport (agentTranscript):
601
+ // run metadata, not part of Codex's answer. Each entry matches the bilingual
602
+ // "Label: value" line so the flat-text fallback can split them out of the
603
+ // body and demote them into the same muted meta block the structured render
604
+ // uses. Order mirrors the structured meta order.
605
+ const FLAT_REPORT_STATUS_SECTIONS = [
606
+ { key: 'unchangedReason', prefixes: ['Why nothing changed:', '未修改原因:'], en: 'Why nothing changed', zh: '未修改原因' },
607
+ { key: 'writeResult', prefixes: ['Write result:', '写入结果:'], en: 'Write result', zh: '写入结果' },
608
+ { key: 'undo', prefixes: ['Undo:', '可撤销:'], en: 'Undo', zh: '可撤销' },
609
+ { key: 'nextStep', prefixes: ['Next:', '下一步:'], en: 'Next', zh: '下一步' }
610
+ ];
611
+
612
+ // Splits a flat completion-report string (formatHumanReport output) into the
613
+ // answer body and the demoted meta rows. Only single-line sections whose head
614
+ // matches a known status label are demoted, so a multi-paragraph conclusion
615
+ // that happens to contain "Next: …" prose stays in the body.
616
+ function splitFlatCompletionReport(text) {
617
+ const raw = typeof text === 'string' ? text : '';
618
+ if (!raw.trim()) {
619
+ return { body: raw, meta: [] };
620
+ }
621
+ const bodySections = [];
622
+ const meta = [];
623
+ for (const section of raw.split(/\n{2,}/)) {
624
+ const trimmed = section.trim();
625
+ if (!trimmed) continue;
626
+ const entry = trimmed.includes('\n')
627
+ ? null
628
+ : FLAT_REPORT_STATUS_SECTIONS.find(item => item.prefixes.some(prefix => trimmed.startsWith(prefix)));
629
+ if (entry) {
630
+ const prefix = entry.prefixes.find(p => trimmed.startsWith(p));
631
+ const value = trimmed.slice(prefix.length).trim();
632
+ if (value) {
633
+ meta.push({ key: entry.key, label: tx(entry.en, entry.zh), value });
634
+ continue;
635
+ }
636
+ }
637
+ bodySections.push(trimmed);
638
+ }
639
+ return { body: bodySections.join('\n\n'), meta };
640
+ }
641
+
642
+ // Renders the demoted run-metadata block (Why nothing changed / Write result /
643
+ // Undo / Next) beneath the answer. Shared by the structured and flat-fallback
644
+ // render paths so both demote identically.
645
+ function appendCompletionMetaBlock(report, meta) {
646
+ if (!Array.isArray(meta) || !meta.length) {
647
+ return;
648
+ }
649
+ const metaBlock = document.createElement('dl');
650
+ metaBlock.className = 'run-final-answer__meta';
651
+ for (const row of meta) {
652
+ if (!row || !row.label || !row.value) continue;
653
+ const dt = document.createElement('dt');
654
+ dt.className = 'run-final-answer__meta-label';
655
+ dt.textContent = row.label;
656
+ if (row.key) dt.dataset.metaKey = row.key;
657
+ const dd = document.createElement('dd');
658
+ dd.className = 'run-final-answer__meta-value';
659
+ dd.textContent = row.value;
660
+ metaBlock.append(dt, dd);
661
+ }
662
+ if (metaBlock.children.length) {
663
+ report.append(metaBlock);
664
+ }
665
+ }
666
+
667
+ function renderCompletionReport(input, run) {
668
+ const event = sanitizeRunEventForRender(input);
669
+ const report = document.createElement('section');
670
+ report.className = 'run-completion-report';
671
+ report.dataset.status = event.status || 'completed';
672
+ // Compile errors captured post-write get a one-click fix loop: the
673
+ // structured error list rides on the report event (compileErrors).
674
+ const appendCompileFix = target => {
675
+ const compileErrors = Array.isArray(event.compileErrors) ? event.compileErrors.filter(Boolean) : [];
676
+ if (!compileErrors.length || typeof refillComposerForRetry !== 'function') {
677
+ return;
678
+ }
679
+ const fix = buildRecoveryButton('compile_errors',
680
+ tx(`Fix ${compileErrors.length} compile error(s)`, `修复 ${compileErrors.length} 个编译错误`),
681
+ tx('Prefills a task with @compile-log and the captured errors.', '自动组一条带 @compile-log 和错误列表的任务。'));
682
+ fix.addEventListener('click', clickEvent => {
683
+ clickEvent.stopPropagation();
684
+ const lines = compileErrors.slice(0, 5).map(item => `- ${item}`).join('\n');
685
+ refillComposerForRetry(null, tx(
686
+ `@compile-log Fix these LaTeX compile errors:\n${lines}`,
687
+ `@compile-log 请修复以下 LaTeX 编译错误:\n${lines}`
688
+ ));
689
+ });
690
+ target.append(fix);
691
+ };
692
+ // Rejected-hunk redo: quote what was dropped during review back into the
693
+ // composer so the next turn can try a different approach.
694
+ const appendRejectedRedo = target => {
695
+ const rejectedHunks = Array.isArray(event.rejectedHunks) ? event.rejectedHunks.filter(item => item?.path) : [];
696
+ if (!rejectedHunks.length || typeof refillComposerForRetry !== 'function') {
697
+ return;
698
+ }
699
+ const redo = buildRecoveryButton('rejected_hunks',
700
+ tx(`Redo ${rejectedHunks.length} rejected change(s) differently`, `换种方式重做 ${rejectedHunks.length} 处被拒改动`),
701
+ tx('Prefills a task quoting each rejected change so Codex can try another approach.', '自动组一条任务,引用每处被拒的改动,让 Codex 换一种实现。'));
702
+ redo.addEventListener('click', clickEvent => {
703
+ clickEvent.stopPropagation();
704
+ const lines = rejectedHunks.map(item => `- ${item.path}${item.summary ? ` (${item.summary})` : ''}`).join('\n');
705
+ refillComposerForRetry(null, tx(
706
+ `I rejected these proposed changes in review. Please take a different approach for:\n${lines}`,
707
+ `以下改动在评审中被我拒绝了,请换一种方式重新实现:\n${lines}`
708
+ ));
709
+ });
710
+ target.append(redo);
711
+ };
712
+
713
+ // Structured path: conclusion + body render as the Codex answer; meta
714
+ // rows (Why nothing changed / Write result / Undo / Next) render as a
715
+ // visually demoted block beneath, with a separator and muted color so
716
+ // they read as run metadata rather than part of the answer.
717
+ const structured = event.detailStructured;
718
+ const hasStructured = structured
719
+ && (structured.conclusion || structured.body || (Array.isArray(structured.meta) && structured.meta.length));
720
+ if (hasStructured) {
721
+ const main = document.createElement('div');
722
+ main.className = 'run-final-answer';
723
+ const mainSections = [];
724
+ if (structured.conclusion) {
725
+ mainSections.push(getLocale() === 'zh'
726
+ ? `结论:${structured.conclusion}`
727
+ : `Conclusion: ${structured.conclusion}`);
728
+ }
729
+ if (structured.body) {
730
+ mainSections.push(structured.body);
731
+ }
732
+ if (mainSections.length) {
733
+ renderMarkdownBlockText(main, mainSections.join('\n\n'));
734
+ report.append(main);
735
+ }
736
+
737
+ appendCompletionMetaBlock(report, structured.meta);
738
+ appendCompileFix(report);
739
+ appendRejectedRedo(report);
740
+ appendRecoveryActionForFailure(report, event, run);
741
+ return report;
742
+ }
743
+
744
+ // Legacy fallback: events persisted before structured payloads were added
745
+ // (and the recovered-history shape that uses `detail: { '结论': result }`).
746
+ // Even without a structured payload, split the trailing status sections out
747
+ // of the flat text so Write result / Undo / Next demote into the muted meta
748
+ // block instead of reading as part of the answer.
749
+ const flatText = formatEventDetail(event.detail || {});
750
+ const split = splitFlatCompletionReport(flatText);
751
+ const body = document.createElement('div');
752
+ body.className = 'run-final-answer';
753
+ renderMarkdownBlockText(body, split.body || flatText);
754
+ report.append(body);
755
+ appendCompileFix(report);
756
+ appendRejectedRedo(report);
757
+ appendCompletionMetaBlock(report, split.meta);
758
+ appendRecoveryActionForFailure(report, event, run);
759
+ return report;
760
+ }
761
+
762
+ // For failure codes that have an actionable recovery path the user can
763
+ // invoke directly from the run card, append a button inside the completion
764
+ // report. Today's only consumer is codex_project_locked → "Force-release
765
+ // the stuck task" (covers the page-refresh-then-locked-out scenario where
766
+ // the normal cancel button is hidden because currentRunView is null).
767
+ // Recovery-action registry: failure codes whose "next step" is executable
768
+ // from the panel get a button, not prose. One primary action per failure.
769
+ const RETRYABLE_FAILURE_CODES = new Set([
770
+ 'native_request_failed', 'codex_timeout', 'codex_no_usable_result',
771
+ 'codex_output_limit', 'stale_source_changed', 'patch_anchor_not_found',
772
+ 'target_editor_not_ready', 'write_timeout', 'partial_write_needs_review',
773
+ 'write_operation_failed'
774
+ ]);
775
+ const NATIVE_SETUP_FAILURE_CODES = new Set([
776
+ 'native_bridge_unavailable', 'native_protocol_incompatible'
777
+ ]);
778
+ const OPEN_FILE_FAILURE_CODES = new Set([
779
+ 'target_file_not_active', 'target_file_open_failed', 'target_file_not_found'
780
+ ]);
781
+ const STORAGE_FAILURE_CODES = new Set([
782
+ 'storage_quota_exceeded', 'run_state_persist_failed'
783
+ ]);
784
+ // A missing `codex` binary is fixed in the terminal, not the panel — the
785
+ // action opens the README troubleshooting section (PATH / login checks).
786
+ const CODEX_INSTALL_FAILURE_CODES = new Set(['codex_not_found']);
787
+ const CODEX_CLI_TROUBLESHOOTING_URL = 'https://github.com/Ghqqqq/codex-overleaf-link#faq-and-troubleshooting';
788
+
789
+ function buildRecoveryButton(failureCode, label, title) {
790
+ const button = document.createElement('button');
791
+ button.type = 'button';
792
+ button.className = 'run-final-answer__recovery-action';
793
+ button.dataset.recoveryFor = failureCode;
794
+ button.textContent = label;
795
+ if (title) {
796
+ button.title = title;
797
+ }
798
+ return button;
799
+ }
800
+
801
+ function appendRecoveryActionForFailure(report, event, run) {
802
+ const failureCode = event?.failure?.code || '';
803
+ if (RETRYABLE_FAILURE_CODES.has(failureCode) && typeof refillComposerForRetry === 'function') {
804
+ const retry = buildRecoveryButton(failureCode,
805
+ tx('Edit & resend', '编辑后重发'),
806
+ tx('Put this run\u2019s task back into the composer so you can adjust and resend it.', '把本轮任务填回输入框,修改后可直接重发。'));
807
+ retry.addEventListener('click', clickEvent => {
808
+ clickEvent.stopPropagation();
809
+ refillComposerForRetry(run || null);
810
+ });
811
+ report.append(retry);
812
+ return;
813
+ }
814
+ if (NATIVE_SETUP_FAILURE_CODES.has(failureCode) && typeof showNativeSetupGuidance === 'function') {
815
+ const fix = buildRecoveryButton(failureCode,
816
+ tx('Fix the native host', '修复 native host'),
817
+ tx('Opens the install/update guidance with the copyable command.', '打开安装/更新指引(含可复制命令)。'));
818
+ fix.addEventListener('click', clickEvent => {
819
+ clickEvent.stopPropagation();
820
+ showNativeSetupGuidance();
821
+ });
822
+ report.append(fix);
823
+ return;
824
+ }
825
+ if (OPEN_FILE_FAILURE_CODES.has(failureCode) && event?.failure?.file && typeof openProjectFileForFailure === 'function') {
826
+ const open = buildRecoveryButton(failureCode,
827
+ tx(`Open ${event.failure.file} in Overleaf`, `在 Overleaf 打开 ${event.failure.file}`), '');
828
+ open.addEventListener('click', clickEvent => {
829
+ clickEvent.stopPropagation();
830
+ openProjectFileForFailure(event.failure.file);
831
+ });
832
+ report.append(open);
833
+ return;
834
+ }
835
+ if (STORAGE_FAILURE_CODES.has(failureCode) && typeof openStorageSettings === 'function') {
836
+ const clean = buildRecoveryButton(failureCode,
837
+ tx('Open history & storage cleanup', '打开历史与存储清理'), '');
838
+ clean.addEventListener('click', clickEvent => {
839
+ clickEvent.stopPropagation();
840
+ openStorageSettings();
841
+ });
842
+ report.append(clean);
843
+ return;
844
+ }
845
+ if (CODEX_INSTALL_FAILURE_CODES.has(failureCode)) {
846
+ const guide = buildRecoveryButton(failureCode,
847
+ tx('Codex CLI troubleshooting guide', '查看 Codex CLI 排查指引'),
848
+ tx('Opens the README section on fixing a missing `codex` command (PATH / login).', '打开 README 中「找不到 codex 命令」的排查说明(PATH/登录)。'));
849
+ guide.addEventListener('click', clickEvent => {
850
+ clickEvent.stopPropagation();
851
+ window.open(CODEX_CLI_TROUBLESHOOTING_URL, '_blank', 'noopener');
852
+ });
853
+ report.append(guide);
854
+ return;
855
+ }
856
+ if (failureCode !== 'codex_project_locked') return;
857
+ const button = document.createElement('button');
858
+ button.type = 'button';
859
+ button.className = 'run-final-answer__recovery-action';
860
+ button.dataset.recoveryFor = failureCode;
861
+ button.textContent = tx('Force-release the stuck task', '强制释放卡住的任务');
862
+ button.title = tx(
863
+ 'Sends a force-cancel to the native host that drops the project lock so a new run can start. Use this when refreshing the tab did not free the project.',
864
+ '向 native host 发送强制取消请求,释放当前 Overleaf 项目的占用,让新的任务可以启动。刷新页面没用时使用。'
865
+ );
866
+ button.addEventListener('click', event => {
867
+ event.stopPropagation();
868
+ button.disabled = true;
869
+ button.textContent = tx('Releasing…', '正在释放…');
870
+ forceCancelStuckTaskForCurrentProject().then(result => {
871
+ if (result?.ok) {
872
+ button.textContent = tx('Force-released — you can retry the run.', '已释放,可以重试本轮任务。');
873
+ return;
874
+ }
875
+ // The helper resolves (never throws) with a non-ok result when the
876
+ // request was not delivered. Report the real outcome instead of a
877
+ // blanket success, and re-enable the button so the user can retry.
878
+ button.disabled = false;
879
+ button.textContent = tx('Release failed — refresh the tab, then try again.', '释放失败,请刷新页面后重试。');
880
+ }).catch(() => {
881
+ button.disabled = false;
882
+ button.textContent = tx('Release failed — refresh the tab, then try again.', '释放失败,请刷新页面后重试。');
883
+ });
884
+ });
885
+ report.append(button);
886
+ }
887
+
888
+ // A run belongs to the tracked-change lifecycle iff trackedChangeStatus is set
889
+ // or it still has tracked-change refs. Every other run is a legacy-undo run.
890
+ function isTrackedChangeLifecycleRun(run) {
891
+ if (!run) {
892
+ return false;
893
+ }
894
+ if (typeof run.trackedChangeStatus === 'string' && run.trackedChangeStatus) {
895
+ return true;
896
+ }
897
+ return Array.isArray(run.undoTrackedChanges) && run.undoTrackedChanges.length > 0;
898
+ }
899
+
900
+ function configureUndoButton(root, run) {
901
+ const existing = root.querySelector('[data-run-undo]');
902
+ const button = existing.cloneNode(true);
903
+ existing.replaceWith(button);
904
+
905
+ if (isTrackedChangeLifecycleRun(run)) {
906
+ configureLifecycleUndoButton(button, run);
907
+ return;
908
+ }
909
+
910
+ const undoCount = getRunUndoCount(run);
911
+ if (!undoCount && run.undoStatus !== 'applied') {
912
+ button.hidden = true;
913
+ return;
914
+ }
915
+
916
+ button.hidden = false;
917
+ button.disabled = run.undoStatus === 'running' || run.undoStatus === 'applied';
918
+ button.textContent = run.undoStatus === 'applied'
919
+ ? tr('undoApplied')
920
+ : (run.partialWriteback ? tr('undoPartialRun') : tr('undoRun'));
921
+ button.title = run.undoStatus === 'applied'
922
+ ? tr('undoAppliedTitle')
923
+ : (run.partialWriteback ? tr('undoPartialRunTitle') : tr('undoRunTitle'));
924
+ button.addEventListener('click', event => {
925
+ event.stopPropagation();
926
+ undoRun(run.id);
927
+ });
928
+ }
929
+
930
+ // Renders the Undo button for a tracked-change-lifecycle run from
931
+ // trackedChangeStatus. At a terminal status both buttons stay visible but
932
+ // disabled: `rejected` shows the disabled "Undone" label, `accepted` keeps
933
+ // Undo present but greyed. `pending` is actionable. `needs_review` is an
934
+ // internal retryable proof state, but the primary UI still renders it as the
935
+ // same executable state as `pending`.
936
+ function configureLifecycleUndoButton(button, run) {
937
+ const status = run.trackedChangeStatus || '';
938
+ // §7 settlement matrix: needs_review keeps BOTH controls visible AND
939
+ // actionable. Branch placed before the terminal branches so a
940
+ // needs_review run is never treated as terminal.
941
+ if (status === 'needs_review') {
942
+ const inFlight = trackedChangeInFlight.get(run.id);
943
+ button.hidden = false;
944
+ button.disabled = inFlight === 'reject' || inFlight === 'accept';
945
+ button.textContent = tr('undoRun');
946
+ button.title = tr('undoRunTitle');
947
+ button.addEventListener('click', event => {
948
+ event.stopPropagation();
949
+ undoRun(run.id);
950
+ });
951
+ return;
952
+ }
953
+ if (status === 'rejected') {
954
+ button.hidden = false;
955
+ button.disabled = true;
956
+ button.textContent = tr('undoApplied');
957
+ button.title = tr('undoAppliedTitle');
958
+ return;
959
+ }
960
+ if (status === 'accepted') {
961
+ // The run was accepted — terminal. Undo stays visible but greyed so the
962
+ // card shows both controls disabled, never removed.
963
+ button.hidden = false;
964
+ button.disabled = true;
965
+ button.textContent = tr('undoRun');
966
+ button.title = tr('undoRunTitle');
967
+ return;
968
+ }
969
+ if (status !== 'pending') {
970
+ button.hidden = true;
971
+ return;
972
+ }
973
+ const inFlight = trackedChangeInFlight.get(run.id);
974
+ button.hidden = false;
975
+ button.disabled = inFlight === 'reject' || inFlight === 'accept';
976
+ button.textContent = tr('undoRun');
977
+ button.title = tr('undoRunTitle');
978
+ button.addEventListener('click', event => {
979
+ event.stopPropagation();
980
+ undoRun(run.id);
981
+ });
982
+ }
983
+
984
+ // The blue Accept All control. For legacy-undo runs it stays hidden — Accept
985
+ // All only exists in the tracked-change lifecycle. Mirrors configureUndoButton's
986
+ // clone-and-replace pattern, rendering from trackedChangeStatus.
987
+ function configureAcceptButton(root, run) {
988
+ // A re-render can fire mid-confirm, while wireAcceptInlineConfirm's
989
+ // Confirm/Cancel pair is showing. Those are separate siblings, not the
990
+ // [data-run-accept] node this rebuilds, so drop any stale pair first to
991
+ // start every render from a clean state.
992
+ for (const stale of root.querySelectorAll('[data-run-accept-confirm], [data-run-accept-cancel]')) {
993
+ stale.remove();
994
+ }
995
+
996
+ const existing = root.querySelector('[data-run-accept]');
997
+ const button = existing.cloneNode(true);
998
+ existing.replaceWith(button);
999
+
1000
+ if (!isTrackedChangeLifecycleRun(run)) {
1001
+ button.hidden = true;
1002
+ return;
1003
+ }
1004
+
1005
+ const status = run.trackedChangeStatus || '';
1006
+ // §7 settlement matrix: needs_review keeps BOTH controls visible AND
1007
+ // actionable. It remains an internal retryable proof state, while the
1008
+ // primary button label stays in the same executable state as `pending`.
1009
+ if (status === 'needs_review') {
1010
+ const inFlight = trackedChangeInFlight.get(run.id);
1011
+ button.hidden = false;
1012
+ button.disabled = inFlight === 'accept' || inFlight === 'reject';
1013
+ if (inFlight === 'accept') {
1014
+ button.textContent = tr('runAcceptTrackedConfirming');
1015
+ button.title = tr('runAcceptTrackedConfirming');
1016
+ return;
1017
+ }
1018
+ button.textContent = tr('runAcceptTracked');
1019
+ button.title = tr('runAcceptTrackedTitle');
1020
+ wireAcceptInlineConfirm(button, run.id);
1021
+ return;
1022
+ }
1023
+ if (status === 'accepted') {
1024
+ button.hidden = false;
1025
+ button.disabled = true;
1026
+ button.textContent = tr('runAcceptTrackedDone');
1027
+ button.title = tr('runAcceptTrackedDoneTitle');
1028
+ return;
1029
+ }
1030
+ if (status === 'rejected') {
1031
+ // The run was rejected — terminal. Accept All stays visible but greyed so
1032
+ // the card shows both controls disabled, never removed.
1033
+ button.hidden = false;
1034
+ button.disabled = true;
1035
+ button.textContent = tr('runAcceptTracked');
1036
+ button.title = tr('runAcceptTrackedTitle');
1037
+ return;
1038
+ }
1039
+ if (status !== 'pending') {
1040
+ button.hidden = true;
1041
+ return;
1042
+ }
1043
+
1044
+ const inFlight = trackedChangeInFlight.get(run.id);
1045
+ button.hidden = false;
1046
+ button.disabled = inFlight === 'accept' || inFlight === 'reject';
1047
+ if (inFlight === 'accept') {
1048
+ button.textContent = tr('runAcceptTrackedConfirming');
1049
+ button.title = tr('runAcceptTrackedConfirming');
1050
+ return;
1051
+ }
1052
+ button.textContent = tr('runAcceptTracked');
1053
+ button.title = tr('runAcceptTrackedTitle');
1054
+ wireAcceptInlineConfirm(button, run.id);
1055
+ }
1056
+
1057
+ // The inline confirm flow: the first click swaps Accept All for an inline
1058
+ // "Confirm accept / Cancel" pair; the accept dispatches only on Confirm.
1059
+ function wireAcceptInlineConfirm(button, runId) {
1060
+ button.addEventListener('click', event => {
1061
+ event.stopPropagation();
1062
+ const meta = button.parentElement;
1063
+ if (!meta) {
1064
+ return;
1065
+ }
1066
+ const confirmBtn = document.createElement('button');
1067
+ confirmBtn.type = 'button';
1068
+ confirmBtn.dataset.runAcceptConfirm = '';
1069
+ confirmBtn.textContent = tr('runAcceptTrackedConfirm');
1070
+ const cancelBtn = document.createElement('button');
1071
+ cancelBtn.type = 'button';
1072
+ cancelBtn.dataset.runAcceptCancel = '';
1073
+ cancelBtn.textContent = tr('runAcceptTrackedCancel');
1074
+
1075
+ button.hidden = true;
1076
+ meta.insertBefore(confirmBtn, button);
1077
+ meta.insertBefore(cancelBtn, button);
1078
+
1079
+ cancelBtn.addEventListener('click', cancelEvent => {
1080
+ cancelEvent.stopPropagation();
1081
+ confirmBtn.remove();
1082
+ cancelBtn.remove();
1083
+ button.hidden = false;
1084
+ });
1085
+ confirmBtn.addEventListener('click', confirmEvent => {
1086
+ confirmEvent.stopPropagation();
1087
+ confirmBtn.disabled = true;
1088
+ cancelBtn.disabled = true;
1089
+ acceptRun(runId);
1090
+ });
1091
+ });
1092
+ }
1093
+
1094
+ function refreshRunCard(runId) {
1095
+ const log = getPanel()?.querySelector('[data-log]');
1096
+ const existing = log?.querySelector(`[data-run-id="${cssEscape(runId)}"]`);
1097
+ const run = findRunRecord(runId);
1098
+ if (!log || !existing || !run) {
1099
+ return;
1100
+ }
1101
+ existing.replaceWith(renderRunCard(run));
1102
+ }
1103
+
1104
+ return {
1105
+ resetAutoFollow,
1106
+ bindLogAutoFollow,
1107
+ bumpUnreadIfDetached,
1108
+ scrollLogToBottom,
1109
+ collapseRunProcess,
1110
+ startRunElapsedTick,
1111
+ stopRunElapsedTick,
1112
+ formatProcessedSummary,
1113
+ renderRunHistory,
1114
+ renderRunCard,
1115
+ getRunStatusText,
1116
+ renderRunEvent,
1117
+ upsertStreamEvent,
1118
+ renderCompletionReport,
1119
+ isTrackedChangeLifecycleRun,
1120
+ configureUndoButton,
1121
+ configureAcceptButton
1122
+ };
1123
+ }
1124
+
1125
+ window.CodexOverleafRunTimelineView = { create };
1126
+ })();