codex-overleaf-link 2.5.1 → 2.6.5

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 (111) hide show
  1. package/README.md +149 -398
  2. package/README.zh-CN.md +153 -404
  3. package/extension/bootstrap/manifest.template.json +1 -1
  4. package/extension/src/background.js +148 -3
  5. package/extension/src/backgroundSharedSessions.js +55 -2
  6. package/extension/src/backgroundUpdateCoordinator.js +128 -12
  7. package/extension/src/content/activeTurnControl.js +164 -7
  8. package/extension/src/content/composerAttachments.js +46 -10
  9. package/extension/src/content/composerPanel.js +92 -12
  10. package/extension/src/content/contentRuntime.js +1114 -321
  11. package/extension/src/content/contextTray.js +8 -2
  12. package/extension/src/content/contextUsageView.js +267 -0
  13. package/extension/src/content/diagnosticsController.js +77 -34
  14. package/extension/src/content/diagnosticsExport.js +1 -1
  15. package/extension/src/content/diagnosticsPanel.js +147 -42
  16. package/extension/src/content/generated/content.bundle.js +5761 -1237
  17. package/extension/src/content/generated/content.bundle.meta.json +6 -3
  18. package/extension/src/content/legacyGlobalRegistry.js +4 -0
  19. package/extension/src/content/localSkillsPanel.js +6 -4
  20. package/extension/src/content/markdownDomRenderer.js +5 -3
  21. package/extension/src/content/markdownText.js +53 -14
  22. package/extension/src/content/modelPicker.js +29 -19
  23. package/extension/src/content/nativeCompatibilityController.js +52 -19
  24. package/extension/src/content/otWarmMirror.js +1 -1
  25. package/extension/src/content/pageBridgeClient.js +7 -7
  26. package/extension/src/content/panelMaintenance.js +10 -2
  27. package/extension/src/content/panelRenderer.js +55 -28
  28. package/extension/src/content/pendingInputView.js +1 -1
  29. package/extension/src/content/projectSettingsCoordinator.js +30 -6
  30. package/extension/src/content/providerSettingsDialog.js +28 -19
  31. package/extension/src/content/recentProjects.js +30 -19
  32. package/extension/src/content/runActivitySummary.js +122 -14
  33. package/extension/src/content/runCompletionMeta.js +194 -6
  34. package/extension/src/content/runController.js +2 -1
  35. package/extension/src/content/runFailureNotice.js +8 -1
  36. package/extension/src/content/runPresence.js +18 -8
  37. package/extension/src/content/runQueueScheduler.js +27 -13
  38. package/extension/src/content/runRecoveryActions.js +2 -1
  39. package/extension/src/content/runResultActions.js +73 -24
  40. package/extension/src/content/runReviewController.js +102 -37
  41. package/extension/src/content/runTimelineView.js +224 -25
  42. package/extension/src/content/scopedPersistencePanelState.js +124 -46
  43. package/extension/src/content/selectionContextView.js +30 -3
  44. package/extension/src/content/sensitivePreflight.js +14 -1
  45. package/extension/src/content/sessionManager.js +49 -3
  46. package/extension/src/content/sessionMenuView.js +54 -18
  47. package/extension/src/content/sessionPersistence.js +25 -5
  48. package/extension/src/content/settingsPanel.js +15 -2
  49. package/extension/src/content/settingsWorkbench.js +127 -89
  50. package/extension/src/content/sharedSessionViewSync.js +13 -4
  51. package/extension/src/content/subagentActivityView.js +23 -7
  52. package/extension/src/content/updateNotice.js +60 -6
  53. package/extension/src/content/writebackOrchestrator.js +301 -72
  54. package/extension/src/content/writingStyleSettings.js +70 -17
  55. package/extension/src/page/compileBridge.js +193 -180
  56. package/extension/src/page/overleafCapabilities.js +4 -1
  57. package/extension/src/page/overleafEditor.js +12 -3
  58. package/extension/src/page/overleafProjectSnapshot.js +56 -3
  59. package/extension/src/page/snapshotRouter.js +75 -66
  60. package/extension/src/page/textCoordinates.js +17 -1
  61. package/extension/src/page/textFileCreator.js +9 -1
  62. package/extension/src/page/treeOperations.js +200 -19
  63. package/extension/src/page/writebackRouter.js +172 -113
  64. package/extension/src/pageBridge.js +124 -73
  65. package/extension/src/popup.js +1 -1
  66. package/extension/src/shared/agentTranscript.js +372 -18
  67. package/extension/src/shared/auditRecords.js +3 -0
  68. package/extension/src/shared/compatibility.js +1 -1
  69. package/extension/src/shared/compileAdapter.js +58 -17
  70. package/extension/src/shared/composerAttachmentDraft.js +27 -7
  71. package/extension/src/shared/contextUsage.js +125 -0
  72. package/extension/src/shared/failureReasons.js +57 -3
  73. package/extension/src/shared/i18n.js +315 -29
  74. package/extension/src/shared/lineReferences.js +38 -31
  75. package/extension/src/shared/pageRpcContract.js +1 -1
  76. package/extension/src/shared/panelIcons.js +39 -0
  77. package/extension/src/shared/projectFiles.js +2 -1
  78. package/extension/src/shared/reviewing.js +130 -1
  79. package/extension/src/shared/runActivityModel.js +70 -4
  80. package/extension/src/shared/runExecutionSnapshot.js +13 -0
  81. package/extension/src/shared/scopedPersistenceBrowserAdapter.js +34 -0
  82. package/extension/src/shared/sessionState.js +63 -4
  83. package/extension/src/shared/sessionTitle.js +14 -1
  84. package/extension/src/shared/sharedSessionBridge.js +81 -13
  85. package/extension/src/shared/staleGuard.js +130 -63
  86. package/extension/src/shared/storageDb.js +35 -3
  87. package/extension/src/shared/storageMigration.js +111 -2
  88. package/extension/src/shared/storageRunActions.js +62 -8
  89. package/extension/src/shared/undoOperations.js +126 -28
  90. package/extension/src/shared/updateRuntimeIdentity.js +16 -2
  91. package/extension/src/shared/writebackSettlement.js +21 -3
  92. package/extension/styles/panel.css +1293 -549
  93. package/native-host/src/bootstrapUpdate.js +52 -16
  94. package/native-host/src/chatBridgeRequest.js +60 -6
  95. package/native-host/src/chatBridgeResponse.js +10 -1
  96. package/native-host/src/chatCompletionsBridge.js +29 -2
  97. package/native-host/src/codexPromptAssembly.js +86 -118
  98. package/native-host/src/codexSessionRunner.js +127 -17
  99. package/native-host/src/debugLog.js +27 -3
  100. package/native-host/src/index.js +42 -11
  101. package/native-host/src/localSkills.js +1 -1
  102. package/native-host/src/mirrorWorkspace.js +47 -3
  103. package/native-host/src/nativeEnvironment.js +27 -5
  104. package/native-host/src/providerContextError.js +20 -0
  105. package/native-host/src/providerRuntime.js +1 -0
  106. package/native-host/src/skills/reviewer-response/SKILL.md +65 -0
  107. package/native-host/src/taskRunnerRuntime.js +28 -8
  108. package/native-host/src/textPatch.js +473 -377
  109. package/native-host/src/updateManager.js +144 -21
  110. package/native-host/src/writingStyleRuntime.js +1 -1
  111. package/package.json +5 -8
@@ -15,7 +15,50 @@
15
15
  return JSON.parse(JSON.stringify(value));
16
16
  }
17
17
 
18
- async function applyReviewTransition({ getState, runId, transition, persist, legacyUndo = false, assertCurrent }) {
18
+ // Review owns recovery progress and its settlement decision. Log events,
19
+ // compilation/mirror evidence, and later draft/display data have other writers.
20
+ const REVIEW_FIELDS = ['appliedOperations', 'undoOperations', 'undoBaseFiles', 'undoTrackedChanges',
21
+ 'undoExpectedFiles', 'undoStatus', 'trackedChangeStatus', 'trackedChangeCaptures', 'interruptedDraft',
22
+ 'retryWriteback', 'saveCheck'];
23
+ const REVIEW_FACT_FIELDS = ['documentEffect', 'fileSettlements', 'failures', 'recoveryAvailability', 'recoveryDisposition'];
24
+ const AUXILIARY_EVIDENCE = ['mirrored', 'compiled'];
25
+ const REVIEW_SOURCE_FIELDS = ['id', 'runProjectId', 'runAccountScopeId', 'startedAt', 'nativeEventSeq',
26
+ 'codexThreadId', 'codexTurnId', 'executionSnapshot', 'forkSnapshot'];
27
+ const factsOf = record => record?.settlement || record?.settlementFacts || {};
28
+ function reviewSnapshot(record, SessionState) {
29
+ const normalized = SessionState?.normalizeRuns?.([record])?.[0] || record;
30
+ const facts = factsOf(normalized);
31
+ return JSON.stringify([
32
+ [...REVIEW_SOURCE_FIELDS, ...REVIEW_FIELDS].map(key => normalized[key]),
33
+ REVIEW_FACT_FIELDS.map(key => facts[key]),
34
+ Object.entries(facts.evidence || {}).filter(([key]) => !AUXILIARY_EVIDENCE.includes(key)).sort(([a], [b]) => a.localeCompare(b))
35
+ ]);
36
+ }
37
+ function restoreReviewFields(record, previous, staged, legacyUndo) {
38
+ for (const key of REVIEW_FIELDS) {
39
+ if (Object.hasOwn(previous, key)) record[key] = cloneSnapshot(previous[key]);
40
+ else delete record[key];
41
+ }
42
+ Object.assign(record, legacyUndo ? { undoStatus: 'partial' } : { trackedChangeStatus: 'needs_review' });
43
+ const currentFacts = factsOf(record), stagedFacts = factsOf(staged);
44
+ const restored = cloneSnapshot(factsOf(previous));
45
+ const preserveLateFields = (target, current, initial, excluded) => {
46
+ for (const key of new Set([...Object.keys(current), ...Object.keys(initial)])) {
47
+ if (excluded.includes(key) || JSON.stringify(current[key]) === JSON.stringify(initial[key])) continue;
48
+ if (Object.hasOwn(current, key)) target[key] = cloneSnapshot(current[key]); else delete target[key];
49
+ }
50
+ };
51
+ preserveLateFields(restored, currentFacts, stagedFacts, [...REVIEW_FACT_FIELDS, 'evidence']);
52
+ const evidence = { ...(restored.evidence || {}) };
53
+ const currentEvidence = currentFacts.evidence || {}, stagedEvidence = stagedFacts.evidence || {};
54
+ preserveLateFields(evidence, currentEvidence, stagedEvidence,
55
+ [...Object.keys(currentEvidence), ...Object.keys(stagedEvidence)].filter(key => !AUXILIARY_EVIDENCE.includes(key)));
56
+ if (Object.keys(evidence).length) restored.evidence = evidence; else delete restored.evidence;
57
+ delete record.settlementFacts;
58
+ if (Object.keys(restored).length) record.settlement = restored; else delete record.settlement;
59
+ }
60
+
61
+ async function applyReviewTransition({ getState, runId, transition, persist, legacyUndo = false, assertCurrent, SessionState }) {
19
62
  const targets = () => Array.from(new Set([
20
63
  ...(getState().runs || []),
21
64
  ...(getState().sessions || []).flatMap(session => session.runs || [])
@@ -33,22 +76,31 @@
33
76
  (Date.parse(session.lastActivityAt) || 0) + 1)).toISOString();
34
77
  session.lastActivityAt = session.updatedAt;
35
78
  }
36
- const staged = records.map(record => JSON.stringify(record));
79
+ const snapshot = record => reviewSnapshot(record, SessionState);
80
+ const stagedRecords = records.map(cloneSnapshot), staged = records.map(snapshot);
81
+ const sameRecord = (original, record) => original === record
82
+ || (assertCurrent && SessionState?.sameRunIdentity?.(original, record));
83
+ const stillStaged = record => records.some((original, index) =>
84
+ sameRecord(original, record) && snapshot(record) === staged[index]);
85
+ const sameView = () => getState().activeSessionId === capturedSessionId
86
+ && (getState() === capturedState || (assertCurrent && SessionState?.sameRunIdentity));
37
87
  try {
38
88
  await persist({ preserveRunActionPayload: true, reviewRunIds: [runId], requireDurable: true });
39
89
  assertCurrent?.();
40
- if (getState() !== capturedState || getState().activeSessionId !== capturedSessionId
41
- || records.some(record => !targets().includes(record))) throw reviewPersistenceError();
90
+ const current = targets();
91
+ if (!sameView() || !current.length || current.some(record => !stillStaged(record))
92
+ || records.some(original => !current.some(record => sameRecord(original, record))))
93
+ throw reviewPersistenceError();
42
94
  } catch (error) {
43
95
  try { assertCurrent?.(); } catch (_stale) { throw error; }
96
+ // The envelope still failed closed, but an exact run-scoped readback
97
+ // already acknowledges this outcome. Keep the error/notice, not a false retry.
98
+ if (error.code === 'shared_session_conflict' && error.durableReviewRunIds?.includes(runId)) throw error;
44
99
  // The remote action may have succeeded. Keep its recovery data and expose uncertainty.
45
- for (const record of records) {
46
- if (getState() !== capturedState || !targets().includes(record)
47
- || JSON.stringify(record) !== staged[records.indexOf(record)]) continue;
48
- Object.assign(record, cloneSnapshot(previous), legacyUndo ? { undoStatus: 'partial' } : { trackedChangeStatus: 'needs_review' });
49
- for (const field of ['settlement', 'settlementFacts']) {
50
- if (!Object.prototype.hasOwnProperty.call(previous, field)) delete record[field];
51
- }
100
+ for (const record of targets()) {
101
+ if (!sameView() || !stillStaged(record)) continue;
102
+ const index = records.findIndex((original, index) => sameRecord(original, record) && snapshot(record) === staged[index]);
103
+ restoreReviewFields(record, previous, stagedRecords[index], legacyUndo);
52
104
  }
53
105
  throw error;
54
106
  }
@@ -82,6 +134,19 @@
82
134
  }
83
135
  }
84
136
 
137
+ function projectPreferenceValues(input, prefs, projectId) {
138
+ return {
139
+ experimentalOtByProject: input.normalizeExperimentalOtByProject(prefs.experimentalOtByProject)[projectId] === true,
140
+ reviewingAvailabilityByProject: prefs.reviewingAvailabilityByProject?.[projectId],
141
+ governanceRulesByProject: input.normalizeGovernanceRulesByProject({
142
+ [projectId]: prefs.governanceRulesByProject?.[projectId] || {}
143
+ })[projectId],
144
+ customInstructionsByProject: input.normalizeCustomInstructionsByProject({
145
+ [projectId]: prefs.customInstructionsByProject?.[projectId] || ''
146
+ })[projectId]
147
+ };
148
+ }
149
+
85
150
  async function persistPanelState(input) {
86
151
  var state = cloneSnapshot(input.state || {});
87
152
  var compactState = cloneSnapshot(input.compactState || {});
@@ -111,6 +176,11 @@
111
176
  var reviewRuns = (input.reviewRunIds || []).map(id =>
112
177
  (state.sessions || []).flatMap(session => session.runs || []).find(run => run.id === id));
113
178
  if (reviewRuns.some(run => !run)) throw reviewPersistenceError();
179
+ var preferenceBaseline = input.projectPreferenceBaseline
180
+ || Migration.getProjectPreferenceBaseline?.(accountScopeId, projectId);
181
+ var preferenceWrite = input.projectPreferenceWrite || Migration.captureProjectPreferenceWrite?.({
182
+ ...input, state: state, baseline: preferenceBaseline, accountScopeId: accountScopeId, projectId: projectId
183
+ });
114
184
  var latestPrefs = typeof Migration.loadPrefs === 'function'
115
185
  ? await Migration.loadPrefs(accountScopeId, projectId)
116
186
  : {};
@@ -124,30 +194,23 @@
124
194
  projectId,
125
195
  compactState.activeSessionId || state.activeSessionId || ''
126
196
  );
127
- prefs.experimentalOtByProject = Object.assign(
128
- {},
129
- latestPrefs && latestPrefs.experimentalOtByProject || {},
130
- { [projectId]: input.normalizeExperimentalOtByProject(state.experimentalOtByProject)[projectId] === true }
131
- );
132
- prefs.governanceRulesByProject = Object.assign(
133
- {},
134
- latestPrefs && latestPrefs.governanceRulesByProject || {},
135
- input.normalizeGovernanceRulesByProject(state.governanceRulesByProject)
136
- );
137
- var normalizedCustomProject = input.normalizeCustomInstructionsByProject({ [projectId]: '' });
138
- var customProjectId = Object.keys(normalizedCustomProject)[0] || '';
139
- var currentCustom = input.normalizeCustomInstructionsByProject(state.customInstructionsByProject);
140
- prefs.customInstructionsByProject = Object.assign(
141
- {},
142
- latestPrefs && latestPrefs.customInstructionsByProject || {}
143
- );
144
- if (customProjectId) {
145
- prefs.customInstructionsByProject[customProjectId] =
146
- Object.prototype.hasOwnProperty.call(currentCustom, customProjectId)
147
- ? currentCustom[customProjectId]
148
- : '';
197
+ var currentPreferences = projectPreferenceValues(input, state, projectId);
198
+ if (preferenceWrite) {
199
+ Object.assign(prefs, Migration.projectPreferencePatch(preferenceWrite, accountScopeId, projectId));
200
+ } else {
201
+ var baselinePreferences = projectPreferenceValues(input, preferenceBaseline || latestPrefs || {}, projectId);
202
+ for (var name of Object.keys(currentPreferences)) {
203
+ prefs[name] = JSON.stringify(currentPreferences[name]) === JSON.stringify(baselinePreferences[name])
204
+ ? {} : { [projectId]: currentPreferences[name] };
205
+ }
206
+ }
207
+ var savedPrefs = await Migration.savePrefs(prefs, accountScopeId, projectId);
208
+ if (savedPrefs) {
209
+ var fields = preferenceWrite ? Migration.completeProjectPreferenceWrite(preferenceWrite, savedPrefs)
210
+ : Object.keys(currentPreferences);
211
+ var applied = input.applyProjectPreferences?.(savedPrefs, state, fields);
212
+ if (!preferenceWrite && applied !== false) Migration.adoptProjectPreferenceBaseline?.(savedPrefs, accountScopeId, projectId);
149
213
  }
150
- await Migration.savePrefs(prefs, accountScopeId, projectId);
151
214
  var persistenceMeta = input.persistenceContext?.nextMeta || {};
152
215
  var queueTombstones = persistenceMeta.queueTombstones || {};
153
216
  var sessionTombstones = persistenceMeta.sessionTombstones || {};
@@ -171,18 +234,33 @@
171
234
  }), { preserveRunActionPayload: true });
172
235
  });
173
236
  sessionRecords = StorageDb.compactSessionBatchActions?.(sessionRecords, { activeSessionId: state.activeSessionId }) || sessionRecords;
174
- var written = await input.SessionPersistence.writeSessions({
175
- Migration: Migration,
176
- StorageDb: StorageDb,
177
- projectId: projectId,
178
- accountScopeId: accountScopeId,
179
- deletedSessionIds: deletedSessionIds,
180
- sessionRecords: sessionRecords,
181
- reviewRunIds: input.reviewRunIds,
182
- queueClaims: persistenceMeta.queueClaims || {},
183
- queueTombstones: queueTombstones,
184
- writerId: input.persistenceContext?.writerId || ''
185
- });
237
+ var written;
238
+ try {
239
+ written = await input.SessionPersistence.writeSessions({
240
+ Migration: Migration,
241
+ StorageDb: StorageDb,
242
+ projectId: projectId,
243
+ accountScopeId: accountScopeId,
244
+ deletedSessionIds: deletedSessionIds,
245
+ sessionRecords: sessionRecords,
246
+ reviewRunIds: input.reviewRunIds,
247
+ queueClaims: persistenceMeta.queueClaims || {},
248
+ queueTombstones: queueTombstones,
249
+ writerId: input.persistenceContext?.writerId || ''
250
+ });
251
+ } catch (error) {
252
+ if (error.code === 'shared_session_conflict' && error.reviewReceipts && StorageDb.reviewStateSnapshot) {
253
+ error.durableReviewRunIds = (input.reviewRunIds || []).filter(id => sessionRecords.some(expected => {
254
+ const proposed = expected.runs?.find(run => run.id === id);
255
+ const saved = error.reviewReceipts.find(record => record.id === expected.id
256
+ && record.accountScopeId === expected.accountScopeId && record.projectId === expected.projectId)
257
+ ?.runs?.find(run => run.id === id);
258
+ return proposed && saved && StorageDb.reviewStateSnapshot(proposed) === StorageDb.reviewStateSnapshot(saved);
259
+ }));
260
+ delete error.reviewReceipts;
261
+ }
262
+ throw error;
263
+ }
186
264
  if (!reviewRuns.every(reviewRun => (written || []).some(session => (session.runs || []).some(run =>
187
265
  run.id === reviewRun.id
188
266
  && (!reviewRun.trackedChangeStatus && ['applied', 'partial'].includes(reviewRun.undoStatus)
@@ -133,6 +133,28 @@
133
133
  }
134
134
  }
135
135
 
136
+ async function attachInstruction(action, selected) {
137
+ const selection = await attach('edit', selected);
138
+ if (!selection) return;
139
+ const task = deps.getPanel()?.querySelector('[data-task]');
140
+ if (!task) return;
141
+ if (task.value.trim()) {
142
+ deps.toast?.(tx('Kept your draft. The selection is attached for editing.', '已保留你的草稿,选区已附上,仅编辑此处。'));
143
+ return;
144
+ }
145
+ const instructions = {
146
+ polish: tx('Polish the selected passage for clarity and flow. Keep the meaning, citations, labels and math unchanged.',
147
+ '润色选中段落,让表达更清晰流畅,保持原意以及引用、标签、公式不变。'),
148
+ shorten: tx('Shorten the selected passage by about 20%. Keep citations, labels, math and the main claim unchanged.',
149
+ '将选中段落精简约 20%,保留引用、标签、公式和核心论点不变。'),
150
+ translate: tx('Translate the selected passage into English. Keep LaTeX commands, citations, labels and math unchanged.',
151
+ '将选中段落翻译成英文,保留 LaTeX 命令、引用、标签和公式不变。')
152
+ };
153
+ task.value = instructions[action];
154
+ task.setSelectionRange(task.value.length, task.value.length);
155
+ task.dispatchEvent(new Event('input', { bubbles: true }));
156
+ }
157
+
136
158
  function editorFor(node) {
137
159
  const element = node?.nodeType === 1 ? node : node?.parentElement;
138
160
  const editor = element?.closest?.('.cm-editor');
@@ -164,6 +186,9 @@
164
186
  toolbar.setAttribute('aria-label', tx('Selected text actions', '选区操作'));
165
187
  toolbar.querySelector('[data-selection-action="reference"]').textContent = tx('Add to Chat', '加入对话');
166
188
  toolbar.querySelector('[data-selection-action="edit"]').textContent = tx('Edit Selection', '仅编辑此处');
189
+ toolbar.querySelector('[data-selection-action="polish"]').textContent = tx('Polish', '润色');
190
+ toolbar.querySelector('[data-selection-action="shorten"]').textContent = tx('Shorten', '精简');
191
+ toolbar.querySelector('[data-selection-action="translate"]').textContent = tx('Translate', '翻译');
167
192
  }
168
193
 
169
194
  function ensureToolbar() {
@@ -174,8 +199,8 @@
174
199
  toolbar.setAttribute('role', 'toolbar');
175
200
  toolbar.hidden = true;
176
201
  if (typeof toolbar.showPopover === 'function') toolbar.setAttribute('popover', 'manual');
177
- for (const mode of ['reference', 'edit']) {
178
- if (mode === 'edit') {
202
+ for (const mode of ['reference', 'edit', 'polish', 'shorten', 'translate']) {
203
+ if (mode === 'edit' || mode === 'polish') {
179
204
  const divider = document.createElement('span');
180
205
  divider.className = 'codex-selection-action-divider';
181
206
  divider.setAttribute('aria-hidden', 'true');
@@ -186,7 +211,9 @@
186
211
  button.dataset.selectionAction = mode;
187
212
  button.addEventListener('click', () => {
188
213
  const selected = candidate;
189
- if (selected) void attach(mode, selected);
214
+ if (!selected) return;
215
+ if (mode === 'reference' || mode === 'edit') void attach(mode, selected);
216
+ else void attachInstruction(mode, selected);
190
217
  });
191
218
  toolbar.append(button);
192
219
  }
@@ -7,6 +7,10 @@
7
7
  })(typeof window !== 'undefined' ? window : globalThis, function sensitivePreflightFactory() {
8
8
  'use strict';
9
9
 
10
+ // Report evidence admits detector identities only, never matched text or previews.
11
+ const REPORT_DETECTOR_IDS = new Set(['private-key', 'bearer-token', 'api-token', 'aws-access-key',
12
+ 'google-api-key', 'huggingface-token', 'gitlab-token', 'stripe-live-secret', 'jwt-token', 'secret-assignment']);
13
+
10
14
  function create({
11
15
  SensitiveScan, normalizeGovernanceRules, isTextSnapshotFile, sendBackgroundNative,
12
16
  getCurrentProjectId, appendRunEvent, appendCompletionReport, showPluginConfirm, tr, tx
@@ -62,12 +66,21 @@
62
66
  }
63
67
  // Blocking an extra message must not finish the task already in progress.
64
68
  if (guidance) return { blocked: true, findings };
65
- appendCompletionReport({
69
+ const detectorIds = [...new Set(findings.map(finding => finding?.detectorId))];
70
+ appendCompletionReport({ conclusionKey: 'sensitive_blocked',
66
71
  conclusion: tx(
67
72
  'This run was blocked before Codex received project context because sensitive content was detected.',
68
73
  '检测到敏感内容,本轮已在向 Codex 发送项目上下文前阻止。'
69
74
  ),
70
75
  status: 'blocked',
76
+ unchangedReason: tx('Sensitive content was found before sending.', '发送前发现了敏感内容。'),
77
+ failure: { code: 'sensitive_content_blocked', stage: 'preflight', severity: 'blocked',
78
+ retryable: false, changedDocument: false,
79
+ evidence: {
80
+ // An unknown type makes the cause generic, even alongside one known type.
81
+ detectorIds: detectorIds.every(id => REPORT_DETECTOR_IDS.has(id)) ? detectorIds.slice(0, 5) : [],
82
+ findingCount: findings.length
83
+ } },
71
84
  operations: [],
72
85
  applyResults: [],
73
86
  nextStep: tx(
@@ -11,7 +11,8 @@
11
11
  const {
12
12
  SessionPanel,
13
13
  SessionState,
14
- SessionMenuView
14
+ SessionMenuView,
15
+ PanelIcons
15
16
  } = deps;
16
17
  const {
17
18
  createSession,
@@ -30,6 +31,9 @@
30
31
  saveState,
31
32
  saveStateSoon,
32
33
  readPanelInputs,
34
+ flushComposerDraft,
35
+ setSessionTransition,
36
+ getDraftScope,
33
37
  applyStateToPanel,
34
38
  syncSessionProvider,
35
39
  getPanel,
@@ -47,6 +51,7 @@
47
51
  getActiveSession,
48
52
  getPanel,
49
53
  getSessionDisplayTitle,
54
+ icons: PanelIcons,
50
55
  getState,
51
56
  isDisplayableSession,
52
57
  isSessionRunning,
@@ -60,7 +65,31 @@
60
65
  closeSessionMenu
61
66
  } = sessionMenuView;
62
67
 
63
- async function startNewSession() {
68
+ let transitionToken = null;
69
+
70
+ async function withSessionTransition(change) {
71
+ if (transitionToken) return;
72
+ const token = {};
73
+ transitionToken = token;
74
+ const scope = getDraftScope?.();
75
+ try {
76
+ // Lock before the first wait, including an outstanding IME commit.
77
+ await setSessionTransition?.(true);
78
+ if (getDraftScope?.() !== scope) return;
79
+ return await change(scope);
80
+ } finally {
81
+ if (transitionToken === token) {
82
+ transitionToken = null;
83
+ setSessionTransition?.(false);
84
+ }
85
+ }
86
+ }
87
+
88
+ function startNewSession() {
89
+ return withSessionTransition(createNewSession);
90
+ }
91
+
92
+ async function createNewSession(draftScope) {
64
93
  // Leaving a still-running session is allowed but never silent: the run
65
94
  // keeps going in the background and lands in its own session.
66
95
  const runningActive = getActiveSession(getState());
@@ -74,7 +103,10 @@
74
103
  return;
75
104
  }
76
105
  }
106
+ if (getDraftScope?.() !== draftScope) return;
77
107
  readPanelInputs();
108
+ await flushComposerDraft?.();
109
+ if (getDraftScope?.() !== draftScope) return;
78
110
  // Reuse an already-empty, idle active session instead of minting a ghost.
79
111
  // Empty sessions are invisible in the list yet still consume the storage cap
80
112
  // (slice(-20) / maxSessions), so spamming "New Session" could silently evict
@@ -99,6 +131,7 @@
99
131
  if (!evictionApproved) {
100
132
  return;
101
133
  }
134
+ if (getDraftScope?.() !== draftScope) return;
102
135
  }
103
136
  const session = createSession({
104
137
  mode: getState().mode,
@@ -114,15 +147,22 @@
114
147
  activeSessionId: session.id
115
148
  }));
116
149
  applyStateToPanel();
150
+ const destinationScope = getDraftScope?.();
117
151
  await syncSessionProvider?.();
152
+ if (getDraftScope?.() !== destinationScope) return;
118
153
  await saveState();
154
+ if (getDraftScope?.() !== destinationScope) return;
119
155
  getPanel().querySelector('[data-task]')?.focus();
120
156
  }
121
157
 
122
- async function switchSession(sessionId) {
158
+ function switchSession(sessionId) {
123
159
  if (sessionId === getState().activeSessionId) {
124
160
  return;
125
161
  }
162
+ return withSessionTransition(scope => switchToSession(sessionId, scope));
163
+ }
164
+
165
+ async function switchToSession(sessionId, draftScope) {
126
166
  const runningActive = getActiveSession(getState());
127
167
  if (runningActive && isSessionRunning(runningActive)) {
128
168
  const approved = await showPluginConfirm({
@@ -134,10 +174,15 @@
134
174
  return;
135
175
  }
136
176
  }
177
+ if (getDraftScope?.() !== draftScope) return;
137
178
  readPanelInputs();
179
+ await flushComposerDraft?.();
180
+ if (getDraftScope?.() !== draftScope) return;
138
181
  setState(setActiveSession(getState(), sessionId));
139
182
  applyStateToPanel();
183
+ const destinationScope = getDraftScope?.();
140
184
  await syncSessionProvider?.();
185
+ if (getDraftScope?.() !== destinationScope) return;
141
186
  await saveState();
142
187
  }
143
188
 
@@ -295,6 +340,7 @@
295
340
  bindActiveSessionHeader,
296
341
  startNewSession,
297
342
  switchSession,
343
+ isTransitioning: () => transitionToken !== null,
298
344
  deleteSessionWithConfirm,
299
345
  renderSessionList,
300
346
  commitSessionRename,
@@ -11,6 +11,7 @@
11
11
  getPanel,
12
12
  getSessionDisplayTitle,
13
13
  getState,
14
+ icons = null,
14
15
  isDisplayableSession,
15
16
  isSessionRunning,
16
17
  startNewSession,
@@ -20,19 +21,20 @@
20
21
 
21
22
  function applySessionLabel() {
22
23
  const trigger = getPanel().querySelector('[data-session-menu-trigger]');
23
- if (trigger) {
24
- trigger.title = tr('sessionMenuOpen');
25
- trigger.setAttribute('aria-label', tr('sessionMenuOpen'));
26
- }
27
24
  const textNode = getPanel().querySelector('[data-session-label-text]');
28
25
  const deleteButton = getPanel().querySelector('[data-session-delete]');
29
26
  const renameButton = getPanel().querySelector('[data-session-rename]');
30
27
  const renameInput = getPanel().querySelector('[data-session-rename-input]');
31
28
  const active = getActiveSession(getState());
29
+ const label = active && isDisplayableSession(active)
30
+ ? getSessionDisplayTitle(active)
31
+ : tr('newSessionFallback');
32
32
  if (textNode) {
33
- textNode.textContent = active && isDisplayableSession(active)
34
- ? getSessionDisplayTitle(active)
35
- : tr('newSessionFallback');
33
+ textNode.textContent = label;
34
+ }
35
+ if (trigger) {
36
+ trigger.title = `${label} · ${tr('sessionMenuOpen')}`;
37
+ trigger.setAttribute('aria-label', `${tr('sessionMenuOpen')}: ${label}`);
36
38
  }
37
39
  if (deleteButton) {
38
40
  const running = active ? isSessionRunning(active) : false;
@@ -111,11 +113,15 @@
111
113
  .slice()
112
114
  .sort((a, b) => String(b.updatedAt || b.createdAt || '').localeCompare(String(a.updatedAt || a.createdAt || '')));
113
115
  for (const session of sessions) {
114
- const row = document.createElement('button');
115
- row.type = 'button';
116
+ const row = document.createElement('div');
116
117
  row.className = 'codex-session-menu-item';
118
+ row.setAttribute('role', 'none');
117
119
  row.dataset.active = session.id === state?.activeSessionId ? 'true' : 'false';
118
120
  row.dataset.running = isSessionRunning(session) ? 'true' : 'false';
121
+ const switchButton = document.createElement('button');
122
+ switchButton.type = 'button';
123
+ switchButton.className = 'codex-session-menu-switch';
124
+ switchButton.setAttribute('role', 'menuitem');
119
125
  const title = document.createElement('span');
120
126
  title.className = 'codex-session-menu-item-title';
121
127
  title.textContent = getSessionDisplayTitle(session);
@@ -127,26 +133,56 @@
127
133
  if (absoluteTs) {
128
134
  time.title = new Date(absoluteTs).toLocaleString();
129
135
  }
130
- row.append(title, time);
131
- row.addEventListener('click', event => {
136
+ switchButton.append(title, time);
137
+ switchButton.addEventListener('click', event => {
132
138
  event.stopPropagation();
133
139
  closeSessionMenu();
134
140
  if (session.id !== getState()?.activeSessionId) {
135
141
  switchSession(session.id);
136
142
  }
137
143
  });
144
+ const deleteButton = menuAction('codex-session-menu-item-delete', 'close', '',
145
+ row.dataset.running === 'true' ? tr('deleteRunningSession') : tr('deleteSession'),
146
+ () => deleteSessionWithConfirm(session.id));
147
+ deleteButton.setAttribute('role', 'menuitem');
148
+ deleteButton.disabled = row.dataset.running === 'true';
149
+ row.append(switchButton, deleteButton);
138
150
  menu.append(row);
139
151
  }
140
- const createButton = document.createElement('button');
141
- createButton.type = 'button';
142
- createButton.className = 'codex-session-menu-new';
143
- createButton.textContent = `+ ${tr('newSession')}`;
144
- createButton.addEventListener('click', event => {
152
+ const footer = document.createElement('div');
153
+ footer.className = 'codex-session-menu-footer';
154
+ const active = getActiveSession(state);
155
+ const running = active ? isSessionRunning(active) : false;
156
+ const soleEmpty = Boolean(active) && !isDisplayableSession(active) && (state?.sessions || []).length <= 1;
157
+ const createButton = menuAction('codex-session-menu-new', 'plus', tr('sessionMenuNewShort'), tr('newSession'), () => startNewSession());
158
+ const renameButton = menuAction('codex-session-menu-rename', 'pencil', tr('sessionMenuRenameShort'), tr('renameSession'), () => beginActiveSessionRename());
159
+ renameButton.disabled = !active;
160
+ const deleteButton = menuAction('codex-session-menu-delete', 'close', tr('sessionMenuDeleteShort'),
161
+ running ? tr('deleteRunningSession') : tr('deleteSession'), () => {
162
+ if (getState()?.activeSessionId) deleteSessionWithConfirm(getState().activeSessionId);
163
+ });
164
+ deleteButton.disabled = !active || running || soleEmpty;
165
+ footer.append(createButton, renameButton, deleteButton);
166
+ menu.append(footer);
167
+ }
168
+
169
+ function menuAction(className, iconName, label, description, onSelect) {
170
+ const button = document.createElement('button');
171
+ button.type = 'button';
172
+ button.className = className;
173
+ button.title = description;
174
+ button.setAttribute('aria-label', description);
175
+ const glyph = icons?.svg?.(iconName);
176
+ if (glyph) button.insertAdjacentHTML('beforeend', glyph);
177
+ const text = document.createElement('span');
178
+ text.textContent = label;
179
+ button.append(text);
180
+ button.addEventListener('click', event => {
145
181
  event.stopPropagation();
146
182
  closeSessionMenu();
147
- startNewSession();
183
+ onSelect();
148
184
  });
149
- menu.append(createButton);
185
+ return button;
150
186
  }
151
187
 
152
188
  function beginActiveSessionRename() {
@@ -74,20 +74,40 @@
74
74
  .filter(record => !deleted.has(record.id))
75
75
  .map(record => [record.id, record]));
76
76
  const mergedRecords = sessionRecords.map(record => StorageDb.mergeSessionReviewState?.(
77
- record, existingById.get(record.id), options.reviewRunIds
77
+ record, existingById.get(record.id), options.reviewRunIds, { shared: StorageDb.sharedSessionsEnabled?.() === true }
78
78
  ) || record);
79
+ const reviewRunIds = new Set(options.reviewRunIds || []);
80
+ // Shared storage merges local edits and explicitly requested Undo markers
81
+ // against the page baseline. Legacy and ordinary stale saves keep the age guard.
79
82
  const writable = mergedRecords.filter(record => {
80
83
  const existing = existingById.get(record.id);
81
84
  if (!existing) return true;
82
- return (Date.parse(record.updatedAt || '') || 0) >= (Date.parse(existing.updatedAt || '') || 0);
85
+ return (Date.parse(record.updatedAt || '') || 0) >= (Date.parse(existing.updatedAt || '') || 0)
86
+ || (StorageDb.sharedSessionsEnabled?.() === true
87
+ && (StorageDb.hasUnpersistedSharedSession?.(record) === true
88
+ || (record.runs || []).some(run => reviewRunIds.has(run.id) && run.undoOwnerProtocol === 1)));
83
89
  });
84
90
  const cleanedExisting = existingSessions.filter((record, index) =>
85
91
  record !== rawExistingSessions[index] && !writable.some(item => item.id === record.id)
86
92
  );
87
93
  if (cleanedExisting.length || writable.length) {
88
- const written = await StorageDb.putRecords('sessions', [...cleanedExisting, ...writable], {
89
- reviewRunIds: options.reviewRunIds
90
- });
94
+ const reviewReceipts = [];
95
+ if (StorageDb.sharedSessionsEnabled?.() === true && reviewRunIds.size) {
96
+ for (const record of writable) {
97
+ const ids = (record.runs || []).filter(run => reviewRunIds.has(run.id)).map(run => run.id);
98
+ if (ids.length) reviewReceipts.push(await StorageDb.putSessionReviewState(record, ids));
99
+ }
100
+ }
101
+ let written;
102
+ try {
103
+ written = await StorageDb.putRecords('sessions', [...cleanedExisting, ...writable], {
104
+ reviewRunIds: options.reviewRunIds, reviewReceipts
105
+ });
106
+ } catch (error) {
107
+ if (error.code === 'shared_session_conflict' && reviewReceipts.length)
108
+ Object.defineProperty(error, 'reviewReceipts', { value: reviewReceipts, configurable: true });
109
+ throw error;
110
+ }
91
111
  // Check the actual durable result, including a decision already saved
92
112
  // from another origin, instead of acknowledging only the proposed view.
93
113
  return written.filter(record => writable.some(candidate => candidate.id === record.id));