codex-overleaf-link 2.3.0 → 2.3.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.
Files changed (33) hide show
  1. package/README.md +23 -23
  2. package/extension/bootstrap/manifest.template.json +1 -1
  3. package/extension/src/background.js +20 -0
  4. package/extension/src/content/composerPanel.js +0 -2
  5. package/extension/src/content/contentRuntime.js +108 -110
  6. package/extension/src/content/generated/content.bundle.js +566 -331
  7. package/extension/src/content/generated/content.bundle.meta.json +4 -3
  8. package/extension/src/content/markdownText.js +12 -10
  9. package/extension/src/content/mathText.js +155 -9
  10. package/extension/src/content/nativeCompatibilityController.js +2 -2
  11. package/extension/src/content/otWarmMirror.js +1 -1
  12. package/extension/src/content/runTimelineView.js +6 -0
  13. package/extension/src/content/writebackOrchestrator.js +2 -49
  14. package/extension/src/page/trackedChangesLifecycle.js +10 -4
  15. package/extension/src/page/writebackRouter.js +6 -2
  16. package/extension/src/shared/agentTranscript.js +10 -3
  17. package/extension/src/shared/compatibility.js +1 -1
  18. package/extension/src/shared/i18n.js +2 -4
  19. package/extension/src/shared/pageRpcContract.js +2 -2
  20. package/extension/src/shared/runExecutionSnapshot.js +14 -4
  21. package/extension/src/shared/runExecutionSnapshotCodec.js +5 -2
  22. package/extension/src/shared/scopedPersistenceQueuePolicy.js +20 -1
  23. package/extension/src/shared/scopedPersistenceReceipt.js +60 -0
  24. package/extension/src/shared/scopedPersistenceTransaction.js +50 -47
  25. package/extension/src/shared/sessionState.js +46 -11
  26. package/extension/src/shared/storageDb.js +6 -5
  27. package/extension/src/shared/writebackOperationEvidence.js +16 -6
  28. package/extension/src/shared/writebackSettlement.js +28 -6
  29. package/extension/styles/panel.css +18 -8
  30. package/native-host/src/codexPromptAssembly.js +1 -1
  31. package/native-host/src/taskRunnerRuntime.js +11 -81
  32. package/native-host/src/updateManager.js +36 -1
  33. package/package.json +1 -1
@@ -35,8 +35,27 @@
35
35
  }
36
36
 
37
37
  function normalizeMeta(value) {
38
+ var revision = normalizeRevision(value && value.revision);
39
+ var hasDurableRevision = Boolean(value
40
+ && Object.prototype.hasOwnProperty.call(value, 'durableRevision'));
41
+ var durableRevision = hasDurableRevision
42
+ ? Math.min(normalizeRevision(value.durableRevision), revision)
43
+ : revision;
44
+ var pendingValue = value && value.pendingCommit;
45
+ var pendingRevision = normalizeRevision(pendingValue && pendingValue.revision);
46
+ var pendingCommit = pendingValue && typeof pendingValue === 'object'
47
+ && pendingRevision > durableRevision
48
+ && pendingRevision <= revision
49
+ ? {
50
+ revision: pendingRevision,
51
+ writerId: cleanPart(pendingValue.writerId),
52
+ startedAt: cleanPart(pendingValue.startedAt)
53
+ }
54
+ : null;
38
55
  return {
39
- revision: normalizeRevision(value && value.revision),
56
+ revision: revision,
57
+ durableRevision: durableRevision,
58
+ pendingCommit: pendingCommit,
40
59
  queueRevision: normalizeRevision(value && value.queueRevision),
41
60
  writerId: cleanPart(value && value.writerId),
42
61
  updatedAt: cleanPart(value && value.updatedAt),
@@ -0,0 +1,60 @@
1
+ (function (root, factory) {
2
+ if (typeof module === 'object' && module.exports) {
3
+ module.exports = factory();
4
+ } else {
5
+ root.CodexOverleafModuleRegistry.define('ScopedPersistenceReceipt', [], factory);
6
+ }
7
+ })(typeof globalThis !== 'undefined' ? globalThis : this, function () {
8
+ 'use strict';
9
+
10
+ function createFenceMeta(meta, currentMeta, writerId) {
11
+ var startedAt = new Date().toISOString();
12
+ var revision = currentMeta.revision + 1;
13
+ return {
14
+ ...meta,
15
+ revision: revision,
16
+ durableRevision: currentMeta.durableRevision,
17
+ pendingCommit: { revision: revision, writerId: writerId, startedAt: startedAt },
18
+ writerId: writerId,
19
+ updatedAt: startedAt
20
+ };
21
+ }
22
+
23
+ function createCommittedMeta(fenceMeta) {
24
+ return {
25
+ ...fenceMeta,
26
+ durableRevision: fenceMeta.revision,
27
+ pendingCommit: null,
28
+ updatedAt: new Date().toISOString()
29
+ };
30
+ }
31
+
32
+ function createRecoveredMeta(currentMeta, fenceMeta, writerId) {
33
+ var revision = fenceMeta.revision + 1;
34
+ return {
35
+ ...currentMeta,
36
+ revision: revision,
37
+ durableRevision: revision,
38
+ pendingCommit: null,
39
+ queueRevision: fenceMeta.queueRevision + 1,
40
+ writerId: writerId,
41
+ updatedAt: new Date().toISOString()
42
+ };
43
+ }
44
+
45
+ function decorateReceiptError(error, revision) {
46
+ if (error && typeof error === 'object') {
47
+ error.code = error.code || 'persistence_receipt_write_failed';
48
+ error.persistenceActionCommitted = true;
49
+ error.persistenceRevision = revision;
50
+ }
51
+ return error;
52
+ }
53
+
54
+ return Object.freeze({
55
+ createFenceMeta: createFenceMeta,
56
+ createCommittedMeta: createCommittedMeta,
57
+ createRecoveredMeta: createRecoveredMeta,
58
+ decorateReceiptError: decorateReceiptError
59
+ });
60
+ });
@@ -2,15 +2,17 @@
2
2
  if (typeof module === 'object' && module.exports) {
3
3
  module.exports = factory(
4
4
  require('./scopedPersistenceQueuePolicy'),
5
- require('./scopedPersistenceBrowserAdapter')
5
+ require('./scopedPersistenceBrowserAdapter'),
6
+ require('./scopedPersistenceReceipt')
6
7
  );
7
8
  } else {
8
9
  root.CodexOverleafModuleRegistry.define('ScopedPersistenceTransaction', [
9
10
  'ScopedPersistenceQueuePolicy',
10
- 'ScopedPersistenceBrowserAdapter'
11
+ 'ScopedPersistenceBrowserAdapter',
12
+ 'ScopedPersistenceReceipt'
11
13
  ], factory);
12
14
  }
13
- })(typeof globalThis !== 'undefined' ? globalThis : this, function (QueuePolicy, BrowserAdapter) {
15
+ })(typeof globalThis !== 'undefined' ? globalThis : this, function (QueuePolicy, BrowserAdapter, Receipt) {
14
16
  'use strict';
15
17
 
16
18
  var applyQueueMutations = QueuePolicy.applyQueueMutations;
@@ -43,9 +45,9 @@
43
45
  var scope = normalizeScope(inputScope);
44
46
  generation += 1;
45
47
  var tokenGeneration = generation;
46
- active = makeToken(scope, tokenGeneration, 0, false, 0);
48
+ active = tokenFromMeta(scope, tokenGeneration, false, {});
47
49
  var meta = await strictRead(adapter, scope);
48
- var token = makeToken(scope, tokenGeneration, meta.revision, false, meta.queueRevision);
50
+ var token = tokenFromMeta(scope, tokenGeneration, false, meta);
49
51
  if (active && active.generation === tokenGeneration && sameScope(active, token)) active = token;
50
52
  return token;
51
53
  }
@@ -60,10 +62,10 @@
60
62
  });
61
63
  }
62
64
  if (!detached && active && sameScope(active, scope)) {
63
- return makeToken(scope, active.generation, active.revision, false, active.queueRevision);
65
+ return tokenFromMeta(scope, active.generation, false, active);
64
66
  }
65
67
  var meta = await strictRead(adapter, scope);
66
- return makeToken(scope, detached ? 0 : generation, meta.revision, detached, meta.queueRevision);
68
+ return tokenFromMeta(scope, detached ? 0 : generation, detached, meta);
67
69
  }
68
70
 
69
71
  function isCurrent(token) {
@@ -94,13 +96,7 @@
94
96
  if (queueMutations.length
95
97
  && currentMeta.queueRevision !== normalizeRevision(token.queueRevision)) {
96
98
  if (!token.detached && isCurrent(token)) {
97
- active = makeToken(
98
- scope,
99
- token.generation,
100
- currentMeta.revision,
101
- false,
102
- currentMeta.queueRevision
103
- );
99
+ active = tokenFromMeta(scope, token.generation, false, currentMeta);
104
100
  }
105
101
  return {
106
102
  ok: false,
@@ -112,33 +108,26 @@
112
108
  }
113
109
  var projected = applyQueueMutations(currentMeta, queueMutations, writerId);
114
110
  if (!projected.ok) return projected;
115
- var nextMeta = {
111
+ var nextMeta = Receipt.createFenceMeta({
116
112
  ...projected.meta,
117
- revision: currentMeta.revision + 1,
118
- writerId: writerId,
119
- updatedAt: new Date().toISOString(),
120
113
  sessionTombstones: mergeTombstones(
121
114
  projected.meta.sessionTombstones,
122
115
  commitOptions && commitOptions.sessionTombstones,
123
116
  200
124
117
  )
125
- };
118
+ }, currentMeta, writerId);
126
119
  await adapter.write(scope, nextMeta);
127
120
  if (!token.detached && isCurrent(token)) {
128
- active = makeToken(
129
- scope,
130
- token.generation,
131
- nextMeta.revision,
132
- false,
133
- nextMeta.queueRevision
134
- );
121
+ active = tokenFromMeta(scope, token.generation, false, nextMeta);
135
122
  }
136
123
  var value;
137
124
  try {
138
125
  value = await action({
139
126
  scope: scope,
140
127
  baseRevision: currentMeta.revision,
128
+ durableRevision: currentMeta.durableRevision,
141
129
  conflict: conflict,
130
+ previousCommitIncomplete: Boolean(currentMeta.pendingCommit),
142
131
  writerId: writerId,
143
132
  currentMeta: currentMeta,
144
133
  nextMeta: nextMeta
@@ -147,23 +136,11 @@
147
136
  if (queueMutations.some(function (mutation) {
148
137
  return ['remove', 'release', 'restore'].includes(cleanPart(mutation.type));
149
138
  })) {
150
- var recoveredMeta = {
151
- ...currentMeta,
152
- revision: nextMeta.revision + 1,
153
- queueRevision: nextMeta.queueRevision + 1,
154
- writerId: writerId,
155
- updatedAt: new Date().toISOString()
156
- };
139
+ var recoveredMeta = Receipt.createRecoveredMeta(currentMeta, nextMeta, writerId);
157
140
  try {
158
141
  await adapter.write(scope, recoveredMeta);
159
142
  if (!token.detached && isCurrent(token)) {
160
- active = makeToken(
161
- scope,
162
- token.generation,
163
- recoveredMeta.revision,
164
- false,
165
- recoveredMeta.queueRevision
166
- );
143
+ active = tokenFromMeta(scope, token.generation, false, recoveredMeta);
167
144
  }
168
145
  } catch (recoveryError) {
169
146
  if (error && typeof error === 'object') {
@@ -173,13 +150,24 @@
173
150
  }
174
151
  throw error;
175
152
  }
153
+ var committedMeta = Receipt.createCommittedMeta(nextMeta);
154
+ try {
155
+ await adapter.write(scope, committedMeta);
156
+ } catch (error) {
157
+ throw Receipt.decorateReceiptError(error, nextMeta.revision);
158
+ }
159
+ if (!token.detached && isCurrent(token)) {
160
+ active = tokenFromMeta(scope, token.generation, false, committedMeta);
161
+ }
176
162
  return {
177
163
  ok: true,
178
164
  value: value,
179
- revision: nextMeta.revision,
180
- queueRevision: nextMeta.queueRevision,
181
- queueClaims: nextMeta.queueClaims,
165
+ revision: committedMeta.revision,
166
+ durableRevision: committedMeta.durableRevision,
167
+ queueRevision: committedMeta.queueRevision,
168
+ queueClaims: committedMeta.queueClaims,
182
169
  rebased: conflict,
170
+ recoveredIncompleteCommit: Boolean(currentMeta.pendingCommit),
183
171
  superseded: !token.detached && !isCurrent(token)
184
172
  };
185
173
  });
@@ -190,9 +178,7 @@
190
178
  captureCommitView: captureCommitView,
191
179
  commit: commit,
192
180
  getActiveView: function () {
193
- return active
194
- ? makeToken(active, active.generation, active.revision, false, active.queueRevision)
195
- : null;
181
+ return active ? tokenFromMeta(active, active.generation, false, active) : null;
196
182
  },
197
183
  getWriterId: function () { return writerId; },
198
184
  invalidate: function () {
@@ -203,17 +189,34 @@
203
189
  });
204
190
  }
205
191
 
206
- function makeToken(scope, tokenGeneration, revision, detached, queueRevision) {
192
+ function makeToken(
193
+ scope,
194
+ tokenGeneration,
195
+ revision,
196
+ detached,
197
+ queueRevision,
198
+ durableRevision,
199
+ pendingCommit
200
+ ) {
207
201
  return Object.freeze({
208
202
  accountScopeId: scope.accountScopeId,
209
203
  projectId: scope.projectId,
210
204
  generation: normalizeRevision(tokenGeneration),
211
205
  revision: normalizeRevision(revision),
206
+ durableRevision: normalizeRevision(durableRevision),
207
+ pendingCommit: pendingCommit && typeof pendingCommit === 'object'
208
+ ? Object.freeze({ ...pendingCommit })
209
+ : null,
212
210
  queueRevision: normalizeRevision(queueRevision),
213
211
  detached: detached === true
214
212
  });
215
213
  }
216
214
 
215
+ function tokenFromMeta(scope, tokenGeneration, detached, meta) {
216
+ return makeToken(scope, tokenGeneration, meta.revision, detached, meta.queueRevision,
217
+ meta.durableRevision, meta.pendingCommit);
218
+ }
219
+
217
220
  async function strictRead(adapter, scope) {
218
221
  return normalizeMeta(await adapter.read(scope));
219
222
  }
@@ -24,7 +24,7 @@
24
24
  'use strict';
25
25
 
26
26
  const DEFAULT_PANEL_STATE = {
27
- mode: 'confirm',
27
+ mode: 'ask',
28
28
  providerId: 'builtin',
29
29
  model: 'gpt-5.4',
30
30
  reasoningEffort: 'high',
@@ -47,7 +47,7 @@
47
47
  customInstructionsByProject: {}
48
48
  };
49
49
 
50
- const VALID_MODES = new Set(['ask', 'confirm', 'auto']);
50
+ const VALID_MODES = new Set(['ask', 'auto']);
51
51
  // Keep the persisted/session-level contract aligned with the model picker
52
52
  // and native provider bridge. Custom providers can explicitly disable
53
53
  // reasoning (`none`) or expose the lightest Codex tier (`minimal`). Dropping
@@ -127,9 +127,7 @@
127
127
  ...input
128
128
  };
129
129
 
130
- if (!VALID_MODES.has(state.mode)) {
131
- state.mode = DEFAULT_PANEL_STATE.mode;
132
- }
130
+ state.mode = normalizeMode(state.mode);
133
131
  if (!VALID_REASONING.has(state.reasoningEffort)) {
134
132
  state.reasoningEffort = DEFAULT_PANEL_STATE.reasoningEffort;
135
133
  }
@@ -221,7 +219,7 @@
221
219
  history,
222
220
  runs,
223
221
  task: sanitizeAssistantVisibleText(session.task),
224
- mode: VALID_MODES.has(session.mode) ? session.mode : fallbackState.mode,
222
+ mode: normalizeMode(session.mode, fallbackState.mode),
225
223
  providerId: typeof session.providerId === 'string' && session.providerId.trim()
226
224
  ? session.providerId.trim()
227
225
  : (fallbackState.providerId || 'builtin'),
@@ -234,6 +232,7 @@
234
232
  : fallbackState.speedTier,
235
233
  requireReviewing: session.requireReviewing !== false,
236
234
  focusFiles: normalizeFocusFiles(session.focusFiles),
235
+ projectReferenceFiles: normalizeProjectReferenceFiles(session.projectReferenceFiles),
237
236
  codexThreadId: typeof session.codexThreadId === 'string' ? session.codexThreadId : '',
238
237
  pendingInputs: normalizePendingInputs(
239
238
  session.pendingInputs,
@@ -311,13 +310,14 @@
311
310
  id: active.id,
312
311
  history: Array.isArray(active.history) ? active.history.slice(-10) : [],
313
312
  focusFiles: normalizeFocusFiles(active.focusFiles),
313
+ projectReferenceFiles: normalizeProjectReferenceFiles(active.projectReferenceFiles),
314
314
  codexThreadId: active.codexThreadId || '',
315
315
  providerId: state.providerId || 'builtin'
316
316
  };
317
317
  state.runs = Array.isArray(active.runs) ? active.runs : [];
318
318
  state.task = typeof active.task === 'string' ? active.task : '';
319
319
  state.focusFiles = normalizeFocusFiles(active.focusFiles);
320
- state.mode = VALID_MODES.has(active.mode) ? active.mode : DEFAULT_PANEL_STATE.mode;
320
+ state.mode = normalizeMode(active.mode);
321
321
  state.requireReviewing = active.requireReviewing !== false;
322
322
 
323
323
  return state;
@@ -339,7 +339,7 @@
339
339
  history: normalizeHistoryEntries(overrides.history),
340
340
  runs,
341
341
  task: sanitizeAssistantVisibleText(overrides.task),
342
- mode: VALID_MODES.has(overrides.mode) ? overrides.mode : DEFAULT_PANEL_STATE.mode,
342
+ mode: normalizeMode(overrides.mode),
343
343
  providerId: typeof overrides.providerId === 'string' && overrides.providerId.trim()
344
344
  ? overrides.providerId.trim()
345
345
  : 'builtin',
@@ -352,6 +352,7 @@
352
352
  : DEFAULT_PANEL_STATE.speedTier,
353
353
  requireReviewing: overrides.requireReviewing !== false,
354
354
  focusFiles: normalizeFocusFiles(overrides.focusFiles),
355
+ projectReferenceFiles: normalizeProjectReferenceFiles(overrides.projectReferenceFiles),
355
356
  codexThreadId: typeof overrides.codexThreadId === 'string' ? overrides.codexThreadId : '',
356
357
  pendingInputs: normalizePendingInputs(overrides.pendingInputs)
357
358
  };
@@ -904,6 +905,33 @@
904
905
  return files.slice(0, 5);
905
906
  }
906
907
 
908
+ function normalizeProjectReferenceFiles(value) {
909
+ const seen = new Set();
910
+ const files = [];
911
+ for (const item of Array.isArray(value) ? value : []) {
912
+ const rawPath = typeof item === 'string' ? item : item?.path;
913
+ if (typeof rawPath !== 'string' || !rawPath.trim() || rawPath.length > 512) {
914
+ continue;
915
+ }
916
+ if (/^(?:[A-Za-z]:|[\\/])/.test(rawPath) || rawPath.includes('\0')) {
917
+ continue;
918
+ }
919
+ const path = rawPath.replace(/\\/g, '/').replace(/\/+/g, '/').replace(/^\.\//, '').trim();
920
+ if (!path || path.split('/').some(segment => !segment || segment === '.' || segment === '..') || seen.has(path)) {
921
+ continue;
922
+ }
923
+ seen.add(path);
924
+ files.push({
925
+ path,
926
+ kind: item?.kind === 'binary' ? 'binary' : 'text'
927
+ });
928
+ if (files.length >= 5000) {
929
+ break;
930
+ }
931
+ }
932
+ return files;
933
+ }
934
+
907
935
  function normalizeCustomInstructionsByProject(value) {
908
936
  const result = {};
909
937
  if (!value || typeof value !== 'object' || Array.isArray(value)) {
@@ -994,7 +1022,7 @@
994
1022
  // Skills page was reopened.
995
1023
  codexOverleafSkills: compactCodexOverleafSkillsForStorage(source.codexOverleafSkills),
996
1024
  composerAttachments: normalizeComposerAttachmentsForPersistence(source.composerAttachments),
997
- mode: VALID_MODES.has(active?.mode) ? active.mode : normalizeMode(source.mode),
1025
+ mode: normalizeMode(active?.mode, source.mode),
998
1026
  providerId: typeof source.providerId === 'string' && source.providerId.trim()
999
1027
  ? source.providerId.trim()
1000
1028
  : 'builtin',
@@ -1092,6 +1120,7 @@
1092
1120
  speedTier: normalizeSpeedTier(session.speedTier || fallbackState.speedTier),
1093
1121
  requireReviewing: session.requireReviewing !== false,
1094
1122
  focusFiles: normalizeFocusFiles(session.focusFiles),
1123
+ projectReferenceFiles: normalizeProjectReferenceFiles(session.projectReferenceFiles),
1095
1124
  codexThreadId: typeof session.codexThreadId === 'string' ? session.codexThreadId : '',
1096
1125
  pendingInputs: normalizePendingInputs(session.pendingInputs)
1097
1126
  };
@@ -1339,8 +1368,14 @@
1339
1368
  };
1340
1369
  }
1341
1370
 
1342
- function normalizeMode(mode) {
1343
- return VALID_MODES.has(mode) ? mode : DEFAULT_PANEL_STATE.mode;
1371
+ function normalizeMode(mode, fallback = DEFAULT_PANEL_STATE.mode) {
1372
+ if (mode === 'confirm') {
1373
+ return 'ask';
1374
+ }
1375
+ if (VALID_MODES.has(mode)) {
1376
+ return mode;
1377
+ }
1378
+ return VALID_MODES.has(fallback) ? fallback : DEFAULT_PANEL_STATE.mode;
1344
1379
  }
1345
1380
 
1346
1381
  function normalizeReasoning(reasoningEffort) {
@@ -287,7 +287,7 @@
287
287
  history: compactHistoryForStorage(input.history),
288
288
  runs: compactRunsForStorage(input.runs, options),
289
289
  task: normalizeDisplayTextForStorage(input.task, SESSION_STORAGE_LIMITS.taskChars),
290
- mode: typeof input.mode === 'string' ? input.mode : '', providerId: typeof input.providerId === 'string' && input.providerId ? input.providerId : 'builtin',
290
+ mode: normalizeCurrentMode(input.mode), providerId: typeof input.providerId === 'string' && input.providerId ? input.providerId : 'builtin',
291
291
  model: typeof input.model === 'string' ? input.model : '',
292
292
  reasoningEffort: typeof input.reasoningEffort === 'string' ? input.reasoningEffort : '',
293
293
  speedTier: typeof input.speedTier === 'string' ? input.speedTier : '',
@@ -436,13 +436,17 @@
436
436
 
437
437
  // --- Helpers ---
438
438
 
439
+ function normalizeCurrentMode(mode) {
440
+ return mode === 'auto' ? 'auto' : 'ask';
441
+ }
442
+
439
443
  function extractLightweightPrefs(state, projectId) {
440
444
  var prefs = {
441
445
  storageSchemaVersion: TARGET_SCHEMA_VERSION, providerId: typeof state.providerId === 'string' && state.providerId ? state.providerId : 'builtin',
442
446
  model: typeof state.model === 'string' ? state.model : '',
443
447
  reasoningEffort: typeof state.reasoningEffort === 'string' ? state.reasoningEffort : '',
444
448
  speedTier: typeof state.speedTier === 'string' ? state.speedTier : '',
445
- mode: typeof state.mode === 'string' ? state.mode : '',
449
+ mode: normalizeCurrentMode(state.mode),
446
450
  locale: typeof state.locale === 'string' ? state.locale : '',
447
451
  requireReviewing: state.requireReviewing !== false,
448
452
  autoRecompile: state.autoRecompile !== false,
@@ -1314,11 +1318,9 @@
1314
1318
  }
1315
1319
 
1316
1320
  // --- Utilities ---
1317
-
1318
1321
  function generateId(prefix) {
1319
1322
  return prefix + '_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 8);
1320
1323
  }
1321
-
1322
1324
  function safeJsonStringify(value) {
1323
1325
  try {
1324
1326
  return JSON.stringify(value) || '';
@@ -1326,7 +1328,6 @@
1326
1328
  return '[unserializable]';
1327
1329
  }
1328
1330
  }
1329
-
1330
1331
  var hashString = StorageRunActions.hashString;
1331
1332
 
1332
1333
  return {
@@ -114,12 +114,22 @@
114
114
  }
115
115
 
116
116
  function normalizeTrackedChanges(value) {
117
- return (Array.isArray(value) ? value : []).map(item => ({
118
- key: normalizeText(item?.key),
119
- id: normalizeText(item?.id),
120
- path: normalizeText(item?.path),
121
- label: normalizeText(item?.label)
122
- })).filter(item => item.key || item.id || item.path);
117
+ const normalized = [];
118
+ const seen = new Set();
119
+ for (const item of Array.isArray(value) ? value : []) {
120
+ const change = {
121
+ key: normalizeText(item?.key),
122
+ id: normalizeText(item?.id),
123
+ path: normalizeText(item?.path),
124
+ label: normalizeText(item?.label)
125
+ };
126
+ if (!change.key && !change.id && !change.path) continue;
127
+ const identity = `${change.path}\u0000${change.key || change.id || 'path-only'}`;
128
+ if (seen.has(identity)) continue;
129
+ seen.add(identity);
130
+ normalized.push(change);
131
+ }
132
+ return normalized;
123
133
  }
124
134
 
125
135
  function normalizeStringArray(value) {
@@ -270,9 +270,17 @@
270
270
  }
271
271
  const successful = isSuccessfulTrackedChangeSettlement(input.result);
272
272
  const reviewCodes = kind === 'accept' ? ACCEPT_NEEDS_REVIEW_CODES : REJECT_NEEDS_REVIEW_CODES;
273
- const needsReview = !successful && failures.some(failure =>
273
+ // Reject/Undo is an all-target recovery transaction. A successful entry
274
+ // only proves that one tracked target was restored; any failed/skipped
275
+ // sibling can still leave this run's content in another file. Keep that
276
+ // recovery actionable instead of collapsing a partial multi-file reject
277
+ // into the terminal `rejected` state. Accept retains its established
278
+ // best-effort terminal policy.
279
+ const incompleteReject = kind === 'reject'
280
+ && collectRunResultSkipped(input.result).length > 0;
281
+ const needsReview = incompleteReject || (!successful && failures.some(failure =>
274
282
  failure.terminalState === 'needs_review' || reviewCodes.has(failure.code)
275
- );
283
+ ));
276
284
  const status = needsReview ? 'needs_review' : kind === 'accept' ? 'accepted' : 'rejected';
277
285
  return {
278
286
  decision: status,
@@ -595,7 +603,12 @@
595
603
  : operation;
596
604
  }
597
605
 
598
- function selectExpectedFilesForTrackedUndo(project, operations = [], trackedChanges = []) {
606
+ function selectExpectedFilesForTrackedUndo(
607
+ project,
608
+ operations = [],
609
+ trackedChanges = [],
610
+ previousExpectedFiles = []
611
+ ) {
599
612
  const paths = new Set();
600
613
  for (const change of trackedChanges || []) {
601
614
  if (change?.path) paths.add(change.path);
@@ -604,9 +617,18 @@
604
617
  if (operation?.path) paths.add(operation.path);
605
618
  if (operation?.to) paths.add(operation.to);
606
619
  }
607
- return (project?.files || [])
608
- .filter(file => paths.has(file.path) && typeof file.content === 'string')
609
- .map(file => ({ path: file.path, content: file.content }));
620
+ const expectedByPath = new Map();
621
+ for (const file of previousExpectedFiles || []) {
622
+ if (paths.has(file?.path) && typeof file?.content === 'string' && !expectedByPath.has(file.path)) {
623
+ expectedByPath.set(file.path, { path: file.path, content: file.content });
624
+ }
625
+ }
626
+ for (const file of project?.files || []) {
627
+ if (paths.has(file?.path) && typeof file?.content === 'string' && !expectedByPath.has(file.path)) {
628
+ expectedByPath.set(file.path, { path: file.path, content: file.content });
629
+ }
630
+ }
631
+ return Array.from(expectedByPath.values());
610
632
  }
611
633
 
612
634
  function acceptDiagnosticStatus(step, info) {
@@ -2686,13 +2686,23 @@
2686
2686
  }
2687
2687
 
2688
2688
  #codex-overleaf-panel .run-math--fallback {
2689
- border-radius: 4px;
2690
- background: var(--tl-surface-2);
2691
- color: var(--tl-review);
2692
- padding: 1px 4px;
2689
+ border-bottom: 1px dotted var(--tl-fg-4);
2690
+ border-radius: 0;
2691
+ background: transparent;
2692
+ color: inherit;
2693
+ padding: 0 2px;
2693
2694
  white-space: pre-wrap;
2694
2695
  overflow-wrap: anywhere;
2695
- font: 0.96em/1.4 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
2696
+ font: inherit;
2697
+ }
2698
+
2699
+ #codex-overleaf-panel .run-math--display.run-math--fallback {
2700
+ border: 1px solid var(--tl-border);
2701
+ border-left: 3px solid var(--tl-fg-4);
2702
+ border-radius: 6px;
2703
+ background: var(--tl-surface-2);
2704
+ padding: 8px 10px;
2705
+ text-align: left;
2696
2706
  }
2697
2707
 
2698
2708
  #codex-overleaf-panel .run-stream[data-stream-role="reasoning"] .run-stream-text {
@@ -3173,12 +3183,12 @@
3173
3183
  white-space: nowrap;
3174
3184
  }
3175
3185
 
3176
- /* Mode switch (Ask/Suggest/Auto) — a real segmented control: a bordered track
3177
- holding three choices. Inactive choices are muted; hover is a light fill
3186
+ /* Mode switch (Ask/Auto) — a real segmented control: a bordered track
3187
+ holding two choices. Inactive choices are muted; hover is a light fill
3178
3188
  (distinct from the active accent-wash fill) so hover never reads as active. */
3179
3189
  #codex-overleaf-panel .codex-mode-switch {
3180
3190
  display: grid;
3181
- grid-template-columns: repeat(3, minmax(0, 1fr));
3191
+ grid-template-columns: repeat(2, minmax(0, 1fr));
3182
3192
  gap: 2px;
3183
3193
  min-width: 0;
3184
3194
  padding: 2px;
@@ -64,7 +64,7 @@ function buildCodexTurnPrompt(options = {}) {
64
64
  'Mode for this turn:',
65
65
  `- ${context.mode}`,
66
66
  '- ask: inspect and explain only; do not edit files.',
67
- '- confirm/auto: edit the local workspace directly when the request calls for changes. The browser bridge handles review, confirmation, deletion approval, and syncing back to Overleaf.',
67
+ '- auto: edit the local workspace directly when the request calls for changes. The browser bridge handles authorization, deletion approval, and syncing back to Overleaf.',
68
68
  '',
69
69
  'Write expectation for this turn:',
70
70
  formatWriteExpectation({