codex-overleaf-link 2.4.0 → 2.4.1

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.
@@ -960,12 +960,12 @@
960
960
  // same executable state as `pending`.
961
961
  function configureLifecycleUndoButton(button, run) {
962
962
  const status = run.trackedChangeStatus || '';
963
+ const inFlight = trackedChangeInFlight.get(run.id);
963
964
  const projection = projectRunSettlement(run);
964
965
  // §7 settlement matrix: needs_review keeps BOTH controls visible AND
965
966
  // actionable. Branch placed before the terminal branches so a
966
967
  // needs_review run is never treated as terminal.
967
968
  if (status === 'needs_review') {
968
- const inFlight = trackedChangeInFlight.get(run.id);
969
969
  button.hidden = false;
970
970
  button.disabled = !projection.canUndo || inFlight === 'reject' || inFlight === 'accept';
971
971
  button.textContent = tr('undoRun');
@@ -976,14 +976,14 @@
976
976
  });
977
977
  return;
978
978
  }
979
- if (status === 'rejected') {
979
+ if (status === 'rejected' && !inFlight) {
980
980
  button.hidden = false;
981
981
  button.disabled = true;
982
982
  button.textContent = tr('undoApplied');
983
983
  button.title = tr('undoAppliedTitle');
984
984
  return;
985
985
  }
986
- if (status === 'accepted') {
986
+ if (status === 'accepted' && !inFlight) {
987
987
  // The run was accepted — terminal. Undo stays visible but greyed so the
988
988
  // card shows both controls disabled, never removed.
989
989
  button.hidden = false;
@@ -992,11 +992,10 @@
992
992
  button.title = tr('undoRunTitle');
993
993
  return;
994
994
  }
995
- if (status !== 'pending') {
995
+ if (status !== 'pending' && !inFlight) {
996
996
  button.hidden = true;
997
997
  return;
998
998
  }
999
- const inFlight = trackedChangeInFlight.get(run.id);
1000
999
  button.hidden = false;
1001
1000
  button.disabled = !projection.canUndo || inFlight === 'reject' || inFlight === 'accept';
1002
1001
  button.textContent = tr('undoRun');
@@ -1034,12 +1033,12 @@
1034
1033
  }
1035
1034
 
1036
1035
  const status = run.trackedChangeStatus || '';
1036
+ const inFlight = trackedChangeInFlight.get(run.id);
1037
1037
  const projection = projectRunSettlement(run);
1038
1038
  // §7 settlement matrix: needs_review keeps BOTH controls visible AND
1039
1039
  // actionable. It remains an internal retryable proof state, while the
1040
1040
  // primary button label stays in the same executable state as `pending`.
1041
1041
  if (status === 'needs_review') {
1042
- const inFlight = trackedChangeInFlight.get(run.id);
1043
1042
  button.hidden = false;
1044
1043
  button.disabled = !projection.canAccept || inFlight === 'accept' || inFlight === 'reject';
1045
1044
  if (inFlight === 'accept') {
@@ -1052,14 +1051,14 @@
1052
1051
  wireAcceptInlineConfirm(button, run.id);
1053
1052
  return;
1054
1053
  }
1055
- if (status === 'accepted') {
1054
+ if (status === 'accepted' && !inFlight) {
1056
1055
  button.hidden = false;
1057
1056
  button.disabled = true;
1058
1057
  button.textContent = tr('runAcceptTrackedDone');
1059
1058
  button.title = tr('runAcceptTrackedDoneTitle');
1060
1059
  return;
1061
1060
  }
1062
- if (status === 'rejected') {
1061
+ if (status === 'rejected' && !inFlight) {
1063
1062
  // The run was rejected — terminal. Accept All stays visible but greyed so
1064
1063
  // the card shows both controls disabled, never removed.
1065
1064
  button.hidden = false;
@@ -1068,12 +1067,11 @@
1068
1067
  button.title = tr('runAcceptTrackedTitle');
1069
1068
  return;
1070
1069
  }
1071
- if (status !== 'pending') {
1070
+ if (status !== 'pending' && !inFlight) {
1072
1071
  button.hidden = true;
1073
1072
  return;
1074
1073
  }
1075
1074
 
1076
- const inFlight = trackedChangeInFlight.get(run.id);
1077
1075
  button.hidden = false;
1078
1076
  button.disabled = !projection.canAccept || inFlight === 'accept' || inFlight === 'reject';
1079
1077
  if (inFlight === 'accept') {
@@ -95,6 +95,7 @@
95
95
  }
96
96
  return Object.freeze({
97
97
  createController: createController,
98
- persistPanelState: PanelState?.persistPanelState || null
98
+ persistPanelState: PanelState?.persistPanelState || null,
99
+ applyReviewTransition: PanelState?.applyReviewTransition || null
99
100
  });
100
101
  });
@@ -15,6 +15,42 @@
15
15
  return JSON.parse(JSON.stringify(value));
16
16
  }
17
17
 
18
+ async function applyReviewTransition({ getState, runId, transition, persist }) {
19
+ const targets = () => Array.from(new Set([
20
+ ...(getState().runs || []),
21
+ ...(getState().sessions || []).flatMap(session => session.runs || [])
22
+ ])).filter(run => run.id === runId);
23
+ const records = targets();
24
+ const sessions = (getState().sessions || []).filter(session =>
25
+ (session.runs || []).some(run => run.id === runId));
26
+ if (!records.length || !sessions.length) throw reviewPersistenceError();
27
+ const previous = cloneSnapshot(records[0]);
28
+ for (const record of records) Object.assign(record, cloneSnapshot(transition));
29
+ for (const session of sessions) {
30
+ session.updatedAt = new Date(Math.max(Date.now(), (Date.parse(session.updatedAt) || 0) + 1,
31
+ (Date.parse(session.lastActivityAt) || 0) + 1)).toISOString();
32
+ session.lastActivityAt = session.updatedAt;
33
+ }
34
+ try {
35
+ await persist({ preserveRunActionPayload: true, reviewRunIds: [runId] });
36
+ } catch (error) {
37
+ // The remote action may have succeeded. Keep its recovery data and expose uncertainty.
38
+ for (const record of targets()) {
39
+ Object.assign(record, cloneSnapshot(previous), { trackedChangeStatus: 'needs_review' });
40
+ for (const field of ['settlement', 'settlementFacts']) {
41
+ if (!Object.prototype.hasOwnProperty.call(previous, field)) delete record[field];
42
+ }
43
+ }
44
+ throw error;
45
+ }
46
+ }
47
+
48
+ function reviewPersistenceError() {
49
+ return Object.assign(new Error('The review outcome was not durably saved. Check Overleaf before retrying.'), {
50
+ code: 'review_state_not_persisted'
51
+ });
52
+ }
53
+
18
54
  async function persistPanelState(input) {
19
55
  var state = cloneSnapshot(input.state || {});
20
56
  var compactState = cloneSnapshot(input.compactState || {});
@@ -35,6 +71,9 @@
35
71
  code: 'stale_view'
36
72
  });
37
73
  }
74
+ var reviewRuns = (input.reviewRunIds || []).map(id =>
75
+ (state.sessions || []).flatMap(session => session.runs || []).find(run => run.id === id));
76
+ if (reviewRuns.some(run => !run)) throw reviewPersistenceError();
38
77
  var latestPrefs = typeof Migration.loadPrefs === 'function'
39
78
  ? await Migration.loadPrefs(accountScopeId, projectId)
40
79
  : {};
@@ -94,18 +133,22 @@
94
133
  .filter(function (item) { return !queueTombstones[item?.id]; })
95
134
  }), { preserveRunActionPayload: true });
96
135
  });
97
- await input.SessionPersistence.writeSessions({
136
+ var written = await input.SessionPersistence.writeSessions({
98
137
  Migration: Migration,
99
138
  StorageDb: StorageDb,
100
139
  projectId: projectId,
101
140
  accountScopeId: accountScopeId,
102
141
  deletedSessionIds: deletedSessionIds,
103
142
  sessionRecords: sessionRecords,
143
+ reviewRunIds: input.reviewRunIds,
104
144
  queueClaims: persistenceMeta.queueClaims || {},
105
145
  queueTombstones: queueTombstones,
106
146
  writerId: input.persistenceContext?.writerId || ''
107
147
  });
148
+ if (!reviewRuns.every(reviewRun => (written || []).some(session => (session.runs || []).some(run =>
149
+ run.id === reviewRun.id && run.trackedChangeStatus === reviewRun.trackedChangeStatus
150
+ )))) throw reviewPersistenceError();
108
151
  }
109
152
 
110
- return Object.freeze({ persistPanelState: persistPanelState });
153
+ return Object.freeze({ persistPanelState: persistPanelState, applyReviewTransition: applyReviewTransition });
111
154
  });
@@ -73,7 +73,10 @@
73
73
  const existingById = new Map(existingSessions
74
74
  .filter(record => !deleted.has(record.id))
75
75
  .map(record => [record.id, record]));
76
- const writable = sessionRecords.filter(record => {
76
+ const mergedRecords = sessionRecords.map(record => StorageDb.mergeSessionReviewState?.(
77
+ record, existingById.get(record.id), options.reviewRunIds
78
+ ) || record);
79
+ const writable = mergedRecords.filter(record => {
77
80
  const existing = existingById.get(record.id);
78
81
  if (!existing) return true;
79
82
  return (Date.parse(record.updatedAt || '') || 0) >= (Date.parse(existing.updatedAt || '') || 0);
@@ -38,8 +38,8 @@
38
38
  <div class="codex-custom-instructions-head">
39
39
  <button type="button" data-settings-back title="Back" aria-label="Back">‹</button>
40
40
  <div>
41
- <div class="codex-custom-instructions-title" data-i18n="projectSettingsTitle">Project Settings</div>
42
- <div class="codex-custom-instructions-subtitle" data-i18n="projectSettingsSubtitle">Customize how Codex behaves in this and all projects.</div>
41
+ <div class="codex-custom-instructions-title" data-settings-title data-i18n="projectSettingsTitle">Project Settings</div>
42
+ <div class="codex-custom-instructions-subtitle" data-settings-subtitle data-i18n="projectSettingsSubtitle">Customize how Codex behaves in this and all projects.</div>
43
43
  </div>
44
44
  </div>
45
45
  <div class="codex-project-settings-status" data-project-settings-status></div>
@@ -304,6 +304,7 @@
304
304
  if (!root) {
305
305
  return;
306
306
  }
307
+ refreshSettingsScope(instance, root);
307
308
  const button = getButton(instance);
308
309
  if (button) {
309
310
  button.dataset.active = 'true';
@@ -312,6 +313,18 @@
312
313
  root.querySelector('[data-custom-instructions-input]')?.focus?.();
313
314
  }
314
315
 
316
+ function refreshSettingsScope(instance, root) {
317
+ const accountScope = instance.container?.closest?.('[data-settings-scope]')?.dataset?.settingsScope === 'account';
318
+ const prefix = accountScope ? 'recentProjects_settings' : 'projectSettings';
319
+ for (const [selector, suffix] of [['[data-settings-title]', 'Title'], ['[data-settings-subtitle]', 'Subtitle']]) {
320
+ const label = root.querySelector(selector);
321
+ if (!label) continue;
322
+ const key = prefix + suffix;
323
+ label.setAttribute('data-i18n', key);
324
+ label.textContent = t(instance, key);
325
+ }
326
+ }
327
+
315
328
  function hide(target) {
316
329
  const instance = target?._instance || target;
317
330
  const root = getRoot(instance);
@@ -346,6 +359,7 @@
346
359
  if (!root) {
347
360
  return;
348
361
  }
362
+ refreshSettingsScope(instance, root);
349
363
  if (Object.prototype.hasOwnProperty.call(state, 'customInstructions')) {
350
364
  const input = root.querySelector('[data-custom-instructions-input]');
351
365
  if (input) {
@@ -288,5 +288,48 @@
288
288
  };
289
289
  }
290
290
 
291
- root.CodexOverleafSaveState = { create };
291
+ async function confirmReviewWriteback({ params, result, readSnapshot, getProjectId, delay,
292
+ now = Date.now, timeoutMs = 45000 }) {
293
+ if (result?.ok !== true || result.skipped?.length) return result;
294
+ const expected = new Map((params.expectedFiles || [])
295
+ .filter(file => file?.path && typeof file.content === 'string').map(file => [file.path, file.content]));
296
+ for (const entry of result.applied || []) {
297
+ const path = entry.trackedChange?.path || entry.operation?.path;
298
+ if (expected.has(path) && typeof entry.result?.verifiedContent === 'string') {
299
+ expected.set(path, entry.result.verifiedContent);
300
+ }
301
+ }
302
+ const deadline = now() + Math.max(0, timeoutMs);
303
+ let attempts = 0;
304
+ if (expected.size && typeof readSnapshot === 'function') {
305
+ do {
306
+ if (!params.runProjectId || getProjectId() !== params.runProjectId) break;
307
+ try {
308
+ const snapshot = await readSnapshot({ force: true, maxAgeMs: 0,
309
+ zipTimeoutMs: Math.max(1, Math.min(8000, deadline - now())) });
310
+ attempts++;
311
+ if (getProjectId() !== params.runProjectId) break;
312
+ if (snapshot?.ok === true && Array.from(expected).every(([path, content]) =>
313
+ snapshot.files?.some(file => file.path === path && file.source === 'overleaf-zip'
314
+ && file.content === content))) {
315
+ return { ...result, saveVerification: { ok: true, state: 'verified_saved',
316
+ source: 'overleaf-zip', attempts } };
317
+ }
318
+ } catch (_error) { /* A transient download failure grants no save proof. */ }
319
+ if (now() >= deadline) break;
320
+ await delay(Math.min(300, deadline - now()));
321
+ } while (now() < deadline);
322
+ }
323
+ const reason = 'Undo changed the editor, but its saved server content could not be confirmed. Keep this page open and check Overleaf before retrying.';
324
+ return { ...result, ok: false, saveVerification: { ok: false, state: 'unknown_timeout', attempts },
325
+ skipped: [...(result.skipped || []), { trackedChange: { path: expected.keys().next().value || '' },
326
+ result: { ok: false, code: 'undo_not_verified', reason, failure: {
327
+ code: 'undo_not_verified', stage: 'verification', severity: 'needs_review',
328
+ terminalState: 'needs_review', changedDocument: true, retryable: true, userMessage: reason
329
+ } } }] };
330
+ }
331
+
332
+ const api = { create, confirmReviewWriteback };
333
+ root.CodexOverleafSaveState = api;
334
+ if (typeof module === 'object' && module.exports) module.exports = api;
292
335
  })(typeof window !== 'undefined' ? window : globalThis);
@@ -1630,7 +1630,10 @@
1630
1630
  }
1631
1631
  return {
1632
1632
  acceptTrackedChanges,
1633
- rejectTrackedChanges,
1633
+ rejectTrackedChanges: async params => {
1634
+ const result = await rejectTrackedChanges(params);
1635
+ return deps.confirmReviewWriteback ? deps.confirmReviewWriteback(params, result) : result;
1636
+ },
1634
1637
  collectTrackedChangeRefsForPaths,
1635
1638
  prepareTrackedChangeCapture, getTrackedChangeCaptureStatus,
1636
1639
  captureTrackedWrite,
@@ -1052,7 +1052,7 @@
1052
1052
  return labelPath;
1053
1053
  }
1054
1054
 
1055
- const textPath = normalizeProjectPathCandidate(node.textContent || '');
1055
+ const textPath = normalizeProjectPathCandidate(readFileTreeNodeText(node));
1056
1056
  if (!textPath) {
1057
1057
  const docIdPath = resolveProjectPathFromDomDocId(node);
1058
1058
  const domTreePath = resolveProjectPathFromDomTree(node);
@@ -1181,7 +1181,7 @@
1181
1181
  node.getAttribute?.('data-name'),
1182
1182
  node.getAttribute?.('aria-label'),
1183
1183
  node.getAttribute?.('title'),
1184
- node.textContent || ''
1184
+ readFileTreeNodeText(node)
1185
1185
  ];
1186
1186
  for (const value of candidates) {
1187
1187
  const path = normalizeProjectPathCandidate(value);
@@ -1244,21 +1244,12 @@
1244
1244
  }
1245
1245
 
1246
1246
  function normalizeProjectPathCandidate(value) {
1247
- const text = normalizeDomText(value);
1247
+ const text = String(value || '').trim();
1248
1248
  if (!text || hasMultipleTextPathExtensions(text)) {
1249
1249
  return '';
1250
1250
  }
1251
- const candidates = [
1252
- stripOverleafIconAffixes(text),
1253
- text
1254
- ];
1255
- for (const candidate of candidates) {
1256
- const normalized = normalizeSafeProjectPath(candidate);
1257
- if (normalized && window.CodexOverleafProjectFiles.isTextProjectPath(normalized)) {
1258
- return normalized;
1259
- }
1260
- }
1261
- return '';
1251
+ const normalized = normalizeSafeProjectPath(text);
1252
+ return normalized && window.CodexOverleafProjectFiles.isTextProjectPath(normalized) ? normalized : '';
1262
1253
  }
1263
1254
 
1264
1255
  function normalizeDomText(value) {
@@ -1275,19 +1266,11 @@
1275
1266
  return false;
1276
1267
  }
1277
1268
 
1278
- function stripOverleafIconAffixes(value) {
1279
- let text = normalizeDomText(value);
1280
- for (let index = 0; index < 4; index += 1) {
1281
- const next = text
1282
- .replace(/^(?:description|article|book_5|insert_drive_file|draft|text_snippet|note|chevron_right|expand_more|folder_open|folder)+/i, '')
1283
- .replace(/(?:more_vert\s*Menu|more_vert|Menu)$/i, '')
1284
- .trim();
1285
- if (next === text) {
1286
- break;
1287
- }
1288
- text = next;
1289
- }
1290
- return text;
1269
+ function readFileTreeNodeText(node) {
1270
+ // Icon ligatures can be valid filename prefixes; use the actual label.
1271
+ const names = Array.from(node.querySelectorAll?.('.item-name') || []);
1272
+ if (names.length > 1) return '';
1273
+ return names.length === 1 ? names[0].textContent || '' : node.textContent || '';
1291
1274
  }
1292
1275
 
1293
1276
  function inferPathFromTreeAncestors(node, fileName) {
@@ -1890,7 +1890,7 @@
1890
1890
  captureTrackedWrite, reconcileTrackedChangeCapture,
1891
1891
  waitForTrackedChangeDiff
1892
1892
  } = trackedChangesLifecycleModule.create({
1893
- window,
1893
+ window, confirmReviewWriteback: deps.confirmReviewWriteback,
1894
1894
  checkWritebackRunProjectId,
1895
1895
  buildNoTraceUndoBlockedResult,
1896
1896
  buildReviewingRequiredBlockedResult,
@@ -93,6 +93,9 @@
93
93
  window
94
94
  });
95
95
  writebackRouter = requirePageModule('CodexOverleafWritebackRouter').create({
96
+ confirmReviewWriteback: (params, result) => requirePageModule('CodexOverleafSaveState').confirmReviewWriteback({
97
+ params, result, getProjectId, delay, readSnapshot: options => snapshotRouter.fetchProjectZipSnapshot(options)
98
+ }),
96
99
  createTextFile: textFileCreator.createFile,
97
100
  deleteTextFile: textFileCreator.deleteFile,
98
101
  activeEditorIdentityChanged,
@@ -12,7 +12,7 @@
12
12
  const MIN_NATIVE_VERSION = '1.0.0';
13
13
  const MIN_COMPATIBLE_NATIVE_VERSION = '1.0.0';
14
14
  const MIN_COMPATIBLE_EXTENSION_VERSION = '1.0.0';
15
- const BUILD_TARGET_VERSION = '2.4.0';
15
+ const BUILD_TARGET_VERSION = '2.4.1';
16
16
  const DEFAULT_CHROME_EXTENSION_ID = 'illdpneeeopfffmiepaejglgmhpmdhdc';
17
17
  const REQUIRED_CAPABILITIES = Object.freeze([
18
18
  'bridgePing',
@@ -508,14 +508,39 @@
508
508
  failureReason_editor_project_id_unavailable_next: 'Refresh the Overleaf tab and retry; if it persists, reload the extension.',
509
509
  // Welcome-panel + write-guard: Recent-projects
510
510
  // variant copy. Spec §5.3–§5.5, §5.8, §5.10 are the source of truth.
511
- recentProjects_welcome: 'Codex Overleaf Link',
512
- recentProjects_welcome_subtitle: 'Open a project from the Overleaf list on the left to start.',
513
- recentProjects_empty: "You haven't run any Codex sessions yet. Open a project from the Overleaf list on the left to start.",
514
- recentProjects_sessions_empty: 'No sessions in this project yet',
515
- recentProjects_sessions_toggle: "Show / manage this project's sessions",
516
- recentProjects_sessions_action: 'View runs',
517
- recentProjects_sessions_action_open: 'Hide runs',
518
- recentProjects_clearProject: 'Delete all sessions for this project',
511
+ recentProjects_welcome: 'Continue working',
512
+ recentProjects_focusEyebrow: 'Last conversation',
513
+ recentProjects_historyHeading: 'Other recent conversations',
514
+ recentProjects_historyHint: 'Select to continue',
515
+ recentProjects_projectConversations: "View this project's conversations",
516
+ recentProjects_allProjects: 'All projects',
517
+ recentProjects_showAllConversations: 'View all {count} conversations',
518
+ recentProjects_unstarted: 'Not started \u00b7 {count}',
519
+ recentProjects_unstartedHint: 'Empty conversations are kept here, without deleting their records.',
520
+ recentProjects_noStarted: 'No conversation has been started yet. Empty conversations are available below.',
521
+ recentProjects_savedDraft: 'Saved draft, ready to continue.',
522
+ recentProjects_attention: 'Needs attention',
523
+ recentProjects_unavailableRecords: 'Records with unavailable project links',
524
+ recentProjects_loadingHistory: 'Loading conversations...',
525
+ recentProjects_loadFailed: 'Conversation history could not be read. Please retry.',
526
+ recentProjects_continueHint: 'Open this conversation without automatically starting a task.',
527
+ recentProjects_more: 'More options',
528
+ recentProjects_continue: 'Continue',
529
+ recentProjects_openProject: 'Open project',
530
+ recentProjects_loadingConversation: 'Loading conversation...',
531
+ recentProjects_noConversation: 'No saved conversation',
532
+ recentProjects_noRuns: 'No runs yet',
533
+ recentProjects_previewUnavailable: 'Conversation unavailable',
534
+ recentProjects_previewUnavailableHint: 'Saved conversation could not be loaded.',
535
+ recentProjects_conversationGone: 'This conversation is no longer available. Choose another conversation from the refreshed list.',
536
+ recentProjects_continueFailed: 'Could not open the conversation. Please try again.',
537
+ recentProjects_welcome_subtitle: 'Conversations saved in this browser',
538
+ recentProjects_empty: 'No conversations saved in this browser yet. Open an Overleaf project and start a Codex conversation.',
539
+ recentProjects_sessions_empty: 'No saved conversations in this project',
540
+ recentProjects_sessions_toggle: "Show or manage this project's conversations",
541
+ recentProjects_sessions_action: 'All conversations',
542
+ recentProjects_sessions_action_open: 'Hide conversations',
543
+ recentProjects_clearProject: 'Delete this project\'s conversations',
519
544
  recentProjects_clearProject_title: 'Delete all Codex sessions for {project}?',
520
545
  recentProjects_clearProject_message: 'This permanently deletes all {count} plugin sessions, run history, and matching plugin-local Codex thread history for {project}. It will not modify Overleaf files or global Codex history.',
521
546
  recentProjects_clearProject_confirm: 'Delete all',
@@ -531,15 +556,17 @@
531
556
  recentProjects_degraded: 'Sign in to Overleaf to see your recent Codex projects.',
532
557
  recentProjects_row_projectLinkUnavailable: 'Project link unavailable.',
533
558
  recentProjects_settings_entry: 'Settings & Diagnostics',
534
- recentProjects_badge_pending: 'pending',
535
- recentProjects_badge_accepted: 'accepted',
536
- recentProjects_badge_rejected: 'rejected',
537
- recentProjects_badge_needs_review: 'pending',
538
- recentProjects_badge_running: 'running',
539
- recentProjects_badge_completed: 'completed',
540
- recentProjects_badge_failed: 'failed',
541
- recentProjects_badge_interrupted: 'interrupted',
542
- recentProjects_badge_stale: 'stale',
559
+ recentProjects_settingsTitle: 'General Settings',
560
+ recentProjects_settingsSubtitle: 'Manage Codex preferences and local history across all projects.',
561
+ recentProjects_badge_pending: 'Last run pending',
562
+ recentProjects_badge_accepted: 'Last changes accepted',
563
+ recentProjects_badge_rejected: 'Last changes undone',
564
+ recentProjects_badge_needs_review: 'Last run needs review',
565
+ recentProjects_badge_running: 'Last recorded as running',
566
+ recentProjects_badge_completed: 'Last run completed',
567
+ recentProjects_badge_failed: 'Last run failed',
568
+ recentProjects_badge_interrupted: 'Last run may have been interrupted',
569
+ recentProjects_badge_stale: 'Last run status unconfirmed',
543
570
  recentProjects_unnamed: 'Project \u00b7 {prefix}',
544
571
  recentProjects_zombieRunningNote: 'Marked as running, but it may be a leftover from an interrupted run.',
545
572
  recentProjects_zombieDeleteConfirm: 'This session is marked as running. If no other tab is actually running it, you can delete it anyway.',
@@ -547,9 +574,9 @@
547
574
  recentProjects_degraded_hint: 'Could not identify the Overleaf account on this page, so the project list is hidden to avoid mixing accounts. This usually resolves on a reload.',
548
575
  recentProjects_sessionsCount: '{count} session(s)',
549
576
  recentProjects_welcome_stats: '{projects} projects \u00b7 {sessions} Codex sessions',
550
- recentProjects_badge_background_completed: 'completed in background',
551
- recentProjects_badge_needs_review_after_navigation: 'pending',
552
- recentProjects_badge_abandoned_after_navigation: 'abandoned'
577
+ recentProjects_badge_background_completed: 'Last run completed in background',
578
+ recentProjects_badge_needs_review_after_navigation: 'Last run needs review',
579
+ recentProjects_badge_abandoned_after_navigation: 'Last run stopped after navigation'
553
580
  },
554
581
  zh: {
555
582
  resizePanel: '拖动调整 Codex 面板宽度,双击恢复默认宽度',
@@ -1047,14 +1074,39 @@
1047
1074
  failureReason_editor_project_id_unavailable_next: '请刷新 Overleaf 页面后重试;如果仍然失败,请重新加载扩展。',
1048
1075
  // Welcome-panel + write-guard: Recent-projects
1049
1076
  // variant copy. 见 spec §5.3–§5.5、§5.8、§5.10。
1050
- recentProjects_welcome: 'Codex Overleaf Link',
1051
- recentProjects_welcome_subtitle: '从左侧 Overleaf 项目列表中打开任意项目即可开始。',
1052
- recentProjects_empty: '你还没有运行过 Codex 会话。从左侧 Overleaf 项目列表中打开任意项目即可开始。',
1053
- recentProjects_sessions_empty: '该项目还没有会话',
1054
- recentProjects_sessions_toggle: '查看/管理该项目的会话',
1055
- recentProjects_sessions_action: '查看会话',
1056
- recentProjects_sessions_action_open: '收起会话',
1057
- recentProjects_clearProject: '删除该项目的全部会话',
1077
+ recentProjects_welcome: '继续工作',
1078
+ recentProjects_focusEyebrow: '上次对话',
1079
+ recentProjects_historyHeading: '其他最近对话',
1080
+ recentProjects_historyHint: '点击条目继续',
1081
+ recentProjects_projectConversations: '查看该项目的全部对话',
1082
+ recentProjects_allProjects: '所有项目',
1083
+ recentProjects_showAllConversations: '查看全部 {count} 段对话',
1084
+ recentProjects_unstarted: '未开始 · {count}',
1085
+ recentProjects_unstartedHint: '空白对话收纳在这里,记录不会被删除。',
1086
+ recentProjects_noStarted: '尚无已开始的对话,空白对话可在下方展开。',
1087
+ recentProjects_savedDraft: '草稿已保存,可继续补充。',
1088
+ recentProjects_attention: '需要查看',
1089
+ recentProjects_unavailableRecords: '项目链接不可用的记录',
1090
+ recentProjects_loadingHistory: '正在加载对话…',
1091
+ recentProjects_loadFailed: '暂时无法读取对话历史,请重试。',
1092
+ recentProjects_continueHint: '仅打开这段对话,不会自动启动任务。',
1093
+ recentProjects_more: '更多选项',
1094
+ recentProjects_continue: '继续',
1095
+ recentProjects_openProject: '打开项目',
1096
+ recentProjects_loadingConversation: '正在加载对话…',
1097
+ recentProjects_noConversation: '暂无保存的对话',
1098
+ recentProjects_noRuns: '尚未运行任务',
1099
+ recentProjects_previewUnavailable: '暂时无法读取对话',
1100
+ recentProjects_previewUnavailableHint: '已保存的对话暂时无法读取。',
1101
+ recentProjects_conversationGone: '该对话已不可用,请从更新后的列表中选择其他对话。',
1102
+ recentProjects_continueFailed: '暂时无法打开对话,请重试。',
1103
+ recentProjects_welcome_subtitle: '当前浏览器保存的对话',
1104
+ recentProjects_empty: '当前浏览器还没有保存的对话。打开一个 Overleaf 项目,与 Codex 开始对话即可。',
1105
+ recentProjects_sessions_empty: '该项目还没有保存的对话',
1106
+ recentProjects_sessions_toggle: '查看或管理该项目的对话',
1107
+ recentProjects_sessions_action: '全部对话',
1108
+ recentProjects_sessions_action_open: '收起对话',
1109
+ recentProjects_clearProject: '删除该项目的全部对话',
1058
1110
  recentProjects_clearProject_title: '删除 {project} 的全部 Codex 会话?',
1059
1111
  recentProjects_clearProject_message: '这会永久删除 {project} 的全部 {count} 个插件会话、运行历史及对应的插件本地 Codex 线程历史。不会修改 Overleaf 文件或全局 Codex 历史。',
1060
1112
  recentProjects_clearProject_confirm: '全部删除',
@@ -1070,15 +1122,17 @@
1070
1122
  recentProjects_degraded: '请登录 Overleaf 后查看你最近的 Codex 项目。',
1071
1123
  recentProjects_row_projectLinkUnavailable: '项目链接不可用。',
1072
1124
  recentProjects_settings_entry: '设置与诊断',
1073
- recentProjects_badge_pending: '待处理',
1074
- recentProjects_badge_accepted: '已接受',
1075
- recentProjects_badge_rejected: '已撤回',
1076
- recentProjects_badge_needs_review: '待处理',
1077
- recentProjects_badge_running: '运行中',
1078
- recentProjects_badge_completed: '已完成',
1079
- recentProjects_badge_failed: '失败',
1080
- recentProjects_badge_interrupted: '已中断',
1081
- recentProjects_badge_stale: '久未处理',
1125
+ recentProjects_settingsTitle: '通用设置',
1126
+ recentProjects_settingsSubtitle: '管理适用于所有项目的 Codex 偏好设置与本地历史记录。',
1127
+ recentProjects_badge_pending: '上次运行待处理',
1128
+ recentProjects_badge_accepted: '上次修改已接受',
1129
+ recentProjects_badge_rejected: '上次修改已撤回',
1130
+ recentProjects_badge_needs_review: '上次运行需要检查',
1131
+ recentProjects_badge_running: '上次记录为运行中',
1132
+ recentProjects_badge_completed: '上次运行已完成',
1133
+ recentProjects_badge_failed: '上次运行失败',
1134
+ recentProjects_badge_interrupted: '上次运行可能已中断',
1135
+ recentProjects_badge_stale: '上次运行状态未确认',
1082
1136
  recentProjects_unnamed: '项目 · {prefix}',
1083
1137
  recentProjects_zombieRunningNote: '标记为运行中,但可能是被中断后的残留。',
1084
1138
  recentProjects_zombieDeleteConfirm: '该会话标记为运行中。如果确认没有其他标签页在运行它,可以直接删除。',
@@ -1086,9 +1140,9 @@
1086
1140
  recentProjects_degraded_hint: '无法识别本页的 Overleaf 账号,为避免账号间串数据已隐藏项目列表。通常刷新即可恢复。',
1087
1141
  recentProjects_sessionsCount: '{count} 个会话',
1088
1142
  recentProjects_welcome_stats: '{projects} 个项目 · {sessions} 个 Codex 会话',
1089
- recentProjects_badge_background_completed: '后台已完成',
1090
- recentProjects_badge_needs_review_after_navigation: '待处理',
1091
- recentProjects_badge_abandoned_after_navigation: '已中止'
1143
+ recentProjects_badge_background_completed: '上次运行已在后台完成',
1144
+ recentProjects_badge_needs_review_after_navigation: '上次运行需要检查',
1145
+ recentProjects_badge_abandoned_after_navigation: '上次运行已在离开页面后停止'
1092
1146
  }
1093
1147
  };
1094
1148
 
@@ -1343,6 +1343,7 @@
1343
1343
  clearStore: clearStore,
1344
1344
  clearAllStores: clearAllStores,
1345
1345
  buildSessionRecord: buildSessionRecord,
1346
+ mergeSessionReviewState: StorageRunActions.mergeSessionReviewState,
1346
1347
  buildTurnRecord: buildTurnRecord,
1347
1348
  buildEventRecord: buildEventRecord,
1348
1349
  buildArtifactRecord: buildArtifactRecord,
@@ -159,6 +159,49 @@
159
159
  return out;
160
160
  }
161
161
 
162
+ // Review terminal states are monotonic even when a later session snapshot is stale.
163
+ function mergeSessionReviewState(incoming, existing, reviewRunIds) {
164
+ if (!existing || incoming.id !== existing.id || incoming.projectId !== existing.projectId
165
+ || !incoming.accountScopeId || incoming.accountScopeId !== existing.accountScopeId) return incoming;
166
+ var terminal = run => ['accepted', 'rejected'].includes(run?.trackedChangeStatus);
167
+ var incomingRuns = new Map((incoming.runs || []).map(run => [run.id, run]));
168
+ var existingRuns = new Map((existing.runs || []).map(run => [run.id, run]));
169
+ var requestedIds = new Set(reviewRunIds || []);
170
+ var explicitReview = Array.from(requestedIds).some(id =>
171
+ ['accepted', 'rejected', 'needs_review'].includes(incomingRuns.get(id)?.trackedChangeStatus));
172
+ var newer = (Date.parse(incoming.updatedAt) || 0) >= (Date.parse(existing.updatedAt) || 0);
173
+ if (!newer && !explicitReview) return incoming;
174
+ var base = newer ? incoming : existing;
175
+ var fields = ['trackedChangeStatus', 'undoStatus', 'undoTrackedChanges', 'undoExpectedFiles',
176
+ 'undoOperations', 'undoBaseFiles', 'appliedOperations', 'trackedChangeCaptures', 'settlement', 'settlementFacts'];
177
+ var changed = false;
178
+ var runs = (base.runs || []).map(function (run) {
179
+ var previous = existingRuns.get(run.id), candidate = incomingRuns.get(run.id);
180
+ // Never revive a removed run or change the first durably recorded review decision.
181
+ var source = terminal(previous) ? previous
182
+ : explicitReview && requestedIds.has(run.id) ? candidate : null;
183
+ if (!source || source === run) return run;
184
+ if (source.runProjectId && source.runProjectId !== incoming.projectId) return run;
185
+ var merged = { ...run };
186
+ for (var field of fields) {
187
+ if (Object.prototype.hasOwnProperty.call(source, field)) {
188
+ merged[field] = JSON.parse(JSON.stringify(source[field]));
189
+ } else {
190
+ delete merged[field];
191
+ }
192
+ }
193
+ changed = true;
194
+ return merged;
195
+ });
196
+ if (!changed) return base;
197
+ var merged = { ...base, runs };
198
+ if (explicitReview && !newer) {
199
+ merged.updatedAt = new Date(Math.max(Date.now(), Date.parse(existing.updatedAt) || 0,
200
+ Date.parse(incoming.updatedAt) || 0) + 1).toISOString();
201
+ }
202
+ return merged;
203
+ }
204
+
162
205
  function hashString(value) {
163
206
  var hash = 2166136261;
164
207
  var text = String(value || '');
@@ -171,6 +214,7 @@
171
214
 
172
215
  return {
173
216
  compactStructuredEventValue: compactStructuredEventValue,
217
+ mergeSessionReviewState: mergeSessionReviewState,
174
218
  compactRunsForStorage: compactRunsForStorage,
175
219
  compactRunActionPayload: compactRunActionPayload,
176
220
  compactProviderSnapshot: compactProviderSnapshot,