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
@@ -7,12 +7,23 @@
7
7
  })(typeof window !== 'undefined' ? window : globalThis, function activeTurnControlFactory() {
8
8
  'use strict';
9
9
 
10
+ // Startup has an owner before a Native request exists. This identity is only
11
+ // for ownership probes; nativeRequestId remains reserved for the actual RPC.
12
+ function getStartupRequestId(sessionId, runId) {
13
+ return `startup:${encodeURIComponent(sessionId)}:${encodeURIComponent(runId)}`;
14
+ }
15
+
10
16
  function create(options = {}) {
11
17
  const runtime = options.chrome?.runtime;
12
18
  let port = null;
13
19
  let binding = null;
14
20
  let reconnectTimer = null;
15
21
  let destroyed = false;
22
+ let bindingWaiter = null;
23
+ let bindingSequence = 0;
24
+ let recoveryTimer = null;
25
+ let recoveryGeneration = 0;
26
+ const inventoryRetries = new Map();
16
27
 
17
28
  function connect() {
18
29
  if (destroyed || port || typeof runtime?.connect !== 'function') {
@@ -21,6 +32,11 @@
21
32
  try {
22
33
  const nextPort = runtime.connect({ name: 'codex-overleaf/run-owner' });
23
34
  port = nextPort;
35
+ nextPort.onMessage?.addListener?.(message => {
36
+ if (port !== nextPort || message?.type !== 'bound' || !bindingWaiter || !binding) return;
37
+ if (['requestId', 'projectKey', 'clientRunId', 'sessionId'].every(key => message[key] === binding[key])
38
+ && message.browserOnly === true && message.ownerToken === binding.ownerToken) bindingWaiter(message.ok !== false);
39
+ });
24
40
  nextPort.onDisconnect?.addListener?.(() => {
25
41
  if (port === nextPort) {
26
42
  port = null;
@@ -46,20 +62,39 @@
46
62
  }
47
63
 
48
64
  function bind(value = {}) {
65
+ bindingWaiter?.(false);
49
66
  binding = {
50
67
  requestId: String(value.requestId || ''),
51
68
  projectKey: String(value.projectKey || ''),
52
69
  clientRunId: String(value.clientRunId || ''),
53
- sessionId: String(value.sessionId || '')
70
+ sessionId: String(value.sessionId || ''),
71
+ ...(value.browserOnly === true ? { browserOnly: true, ownerToken: String(value.ownerToken || `browser-${++bindingSequence}`) } : {})
54
72
  };
55
73
  connect();
56
74
  postBinding('bind', binding);
57
75
  }
58
76
 
59
- function release(requestId) {
60
- if (!binding || (requestId && binding.requestId !== requestId)) {
77
+ async function bindBrowserRun(value) {
78
+ bind({ ...value, browserOnly: true });
79
+ if (destroyed || !port) return false;
80
+ return new Promise(resolve => {
81
+ const timeout = setTimeout(() => complete(false), 5000);
82
+ const complete = ok => {
83
+ clearTimeout(timeout);
84
+ if (bindingWaiter === complete) bindingWaiter = null;
85
+ resolve(ok);
86
+ };
87
+ bindingWaiter = complete;
88
+ postBinding('bind', binding);
89
+ });
90
+ }
91
+
92
+ function release(requestId, ownerToken) {
93
+ if (!binding || (requestId && binding.requestId !== requestId)
94
+ || (ownerToken && ownerToken !== binding.ownerToken)) {
61
95
  return;
62
96
  }
97
+ bindingWaiter?.(false);
63
98
  postBinding('release', binding);
64
99
  binding = null;
65
100
  }
@@ -86,12 +121,94 @@
86
121
  return response?.ok === true;
87
122
  }
88
123
 
124
+ async function knownJournals(requestIds, owners = [], onRetry) {
125
+ if (typeof runtime?.sendMessage !== 'function') return null;
126
+ const known = new Set();
127
+ for (let offset = 0; offset < requestIds.length; offset += 64) {
128
+ const ids = requestIds.slice(offset, offset + 64);
129
+ const response = await runtime.sendMessage({ type: 'codex-overleaf/run-journal/known',
130
+ requestIds: ids, owners: owners.slice(offset, offset + 64) });
131
+ // An older background cannot rule out browser-only owners.
132
+ const needsStartupInventory = owners.slice(offset, offset + 64).some(owner => owner.preDispatch === true);
133
+ if (!response?.ok || !Array.isArray(response.known) || response.ownersChecked !== true
134
+ || (needsStartupInventory && response.preDispatchChecked !== true)) {
135
+ if (Number.isFinite(response?.retryAfterMs) && response.retryAfterMs > 0) onRetry?.(response.retryAfterMs);
136
+ return null;
137
+ }
138
+ for (const id of response.known) if (ids.includes(id)) known.add(id);
139
+ }
140
+ return known;
141
+ }
142
+
143
+ // Native journals and live browser-only owners are separate evidence. A
144
+ // missing journal alone cannot establish that another page stopped retrying.
145
+ async function recoverOrphanedRuns(projectKey, options, result, isCurrent) {
146
+ const now = Date.now(), candidates = [];
147
+ let recheckAfterMs = Infinity;
148
+ const sessions = () => options.listSessions?.() || [];
149
+ const requestIdFor = (session, record) => record.nativeRequestId || getStartupRequestId(session.id, record.id);
150
+ const eligible = (session, record) => record.status === 'running' && session.id && record.id
151
+ && !options.isLiveRun?.(record, session)
152
+ && (!session.projectId || session.projectId === projectKey)
153
+ && (!options.accountScopeId || !session.accountScopeId || session.accountScopeId === options.accountScopeId)
154
+ && (!record.runProjectId || record.runProjectId === projectKey);
155
+ for (const session of sessions()) {
156
+ for (const record of session.runs || []) {
157
+ if (!eligible(session, record)) continue;
158
+ const started = Date.parse(record.startedAt);
159
+ if (!Number.isFinite(started)) continue;
160
+ const remaining = started + (options.orphanGraceMs ?? 60000) - now;
161
+ if (remaining > 0) { recheckAfterMs = Math.min(recheckAfterMs, remaining); continue; }
162
+ candidates.push({ session, record, requestId: requestIdFor(session, record) });
163
+ }
164
+ }
165
+ result.recheckAfterMs = Number.isFinite(recheckAfterMs) ? recheckAfterMs : null;
166
+ if (!candidates.length) return;
167
+ const known = await knownJournals(candidates.map(item => item.requestId), candidates.map(({ session, record, requestId }) => ({
168
+ requestId, projectKey, clientRunId: record.id, sessionId: session.id,
169
+ ...(!record.nativeRequestId ? { preDispatch: true } : {})
170
+ })), delay => {
171
+ const key = `${options.accountScopeId || ''}:${projectKey}`;
172
+ const attempts = inventoryRetries.get(key) || 0;
173
+ if (attempts >= 3) return;
174
+ inventoryRetries.set(key, attempts + 1);
175
+ result.recheckAfterMs = Math.min(result.recheckAfterMs ?? Infinity, delay);
176
+ }).catch(() => null);
177
+ if (!known || !isCurrent()) return;
178
+ inventoryRetries.delete(`${options.accountScopeId || ''}:${projectKey}`);
179
+ for (const { session, record, requestId } of candidates) {
180
+ const currentSession = sessions().find(value => value.id === session.id);
181
+ // State normalization can replace run objects while inventory is pending.
182
+ if (!currentSession?.runs?.includes(record) || !eligible(currentSession, record)
183
+ || requestIdFor(currentSession, record) !== requestId || known.has(requestId)) continue;
184
+ if (!record.runProjectId) record.runProjectId = projectKey;
185
+ options.normalizeInterruptedRun?.(record);
186
+ record.status = 'interrupted';
187
+ record.interruptedDraft = { requestId, threadId: currentSession.codexThreadId || '',
188
+ turnId: record.codexTurnId || '', lastEventSeq: record.nativeEventSeq || 0, reason: 'extension_reloaded' };
189
+ options.pauseSessionQueue?.(currentSession, 'page_reload');
190
+ result.changed = true;
191
+ result.recoveredRuns.push({ sessionId: currentSession.id, runId: record.id,
192
+ projectId: projectKey, accountScopeId: options.accountScopeId });
193
+ }
194
+ }
195
+
196
+ function cancelRecovery() {
197
+ recoveryGeneration++;
198
+ clearTimeout(recoveryTimer);
199
+ recoveryTimer = null;
200
+ }
201
+
89
202
  async function recoverJournals(options = {}) {
203
+ cancelRecovery();
204
+ const generation = recoveryGeneration;
205
+ const isCurrent = () => !destroyed && generation === recoveryGeneration && options.isCurrent?.() !== false;
90
206
  const projectKey = String(options.projectKey || '');
91
207
  if (!projectKey) {
92
208
  return { changed: false, acknowledgeIds: [] };
93
209
  }
94
210
  const journals = await listJournals(projectKey).catch(() => []);
211
+ if (!isCurrent()) return { changed: false, acknowledgeIds: [], recoveredRuns: [] };
95
212
  let changed = false;
96
213
  const acknowledgeIds = [];
97
214
  const recoveredRuns = [];
@@ -116,11 +233,19 @@
116
233
  session.runs = [...(session.runs || []), record].slice(-20);
117
234
  }
118
235
  }
119
- if (!record) {
236
+ if (!record || options.isLiveRun?.(record, session)) {
120
237
  continue;
121
238
  }
122
239
  if (record.runProjectId && record.runProjectId !== projectKey) continue;
123
240
  if (!record.runProjectId) record.runProjectId = projectKey;
241
+ if (journal.operation === 'compact') {
242
+ record.operation = 'compact';
243
+ if (!record.codexThreadId) record.codexThreadId = String(journal.threadId || '');
244
+ if (session.contextUsage?.threadId === record.codexThreadId
245
+ && Date.parse(session.contextUsage.observedAt || '') < Date.parse(record.startedAt || '')) {
246
+ session.contextUsage = null;
247
+ }
248
+ }
124
249
  if (record.status === 'running') {
125
250
  options.normalizeInterruptedRun?.(record);
126
251
  }
@@ -140,6 +265,9 @@
140
265
  record.nativeEventSeq = sequence;
141
266
  continue;
142
267
  }
268
+ // Non-display state (for example context usage) must also recover
269
+ // from the journal, after the owning thread/turn has been bound.
270
+ options.onRawEvent?.(raw, { record, session, sequence, journal });
143
271
  const recovered = options.mapEvent?.(raw, journal);
144
272
  if (recovered) {
145
273
  if (recovered.kind === 'stream') {
@@ -170,11 +298,21 @@
170
298
  recoveredRuns.push({ sessionId: session.id, runId: record.id,
171
299
  projectId: projectKey, accountScopeId: options.accountScopeId });
172
300
  }
173
- return { changed, acknowledgeIds, recoveredRuns };
301
+ const result = { changed, acknowledgeIds, recoveredRuns };
302
+ await recoverOrphanedRuns(projectKey, options, result, isCurrent);
303
+ if (isCurrent() && result.recheckAfterMs !== null && typeof options.onRecheck === 'function') {
304
+ recoveryTimer = setTimeout(() => {
305
+ recoveryTimer = null;
306
+ if (isCurrent()) Promise.resolve(options.onRecheck()).catch(() => {});
307
+ }, Math.min(result.recheckAfterMs, 2147483647));
308
+ }
309
+ return result;
174
310
  }
175
311
 
176
312
  function destroy() {
177
313
  destroyed = true;
314
+ cancelRecovery();
315
+ bindingWaiter?.(false);
178
316
  clearTimeout(reconnectTimer);
179
317
  reconnectTimer = null;
180
318
  try {
@@ -187,8 +325,27 @@
187
325
  }
188
326
 
189
327
  connect();
190
- return { acknowledge, bind, destroy, listJournals, recoverJournals, release };
328
+ return { acknowledge, bind, bindBrowserRun, cancelRecovery, destroy, getOwner: () => binding ? { ...binding } : null,
329
+ knownJournals, listJournals, recoverJournals, release };
330
+ }
331
+
332
+ function createInterruptedRun(journal, projectKey, statusText) {
333
+ return { id: journal.clientRunId, task: journal.task || 'interrupted task', mode: journal.mode || 'ask',
334
+ ...(journal.operation === 'compact' ? { operation: 'compact', codexThreadId: String(journal.threadId || '') } : {}),
335
+ model: journal.model || '', status: 'interrupted', statusText, runProjectId: projectKey,
336
+ startedAt: journal.createdAt || new Date().toISOString(), finishedAt: journal.updatedAt || new Date().toISOString(),
337
+ events: [], attachments: [], undoOperations: [], undoTrackedChanges: [], undoExpectedFiles: [] };
338
+ }
339
+
340
+ function mapRecoveredEvent(raw, journal, options) {
341
+ const activity = options.mapActivity(raw);
342
+ if (!activity?.visible || activity.kind === 'technical') return null;
343
+ return { kind: activity.kind || 'activity', title: options.sanitizeText(activity.title) || 'Event',
344
+ status: activity.status || 'running', detail: options.sanitizeValue(activity.detail),
345
+ timestamp: raw.timestamp || journal.updatedAt || new Date().toISOString(),
346
+ streamKey: options.sanitizeText(activity.streamKey), streamRole: options.sanitizeText(activity.streamRole),
347
+ appendText: options.sanitizeText(activity.appendText), replaceText: activity.replaceText };
191
348
  }
192
349
 
193
- return { create };
350
+ return { create, createInterruptedRun, mapRecoveredEvent, getStartupRequestId };
194
351
  });
@@ -7,6 +7,7 @@
7
7
  const tr = typeof options.tr === 'function' ? options.tr : (key) => key;
8
8
  const appendPlainLog = typeof options.appendPlainLog === 'function' ? options.appendPlainLog : () => {};
9
9
  const onChange = typeof options.onChange === 'function' ? options.onChange : () => {};
10
+ const icons = options.icons || null;
10
11
  const limits = options.limits || {};
11
12
  const maxAttachments = Number(limits.maxAttachments) || 8;
12
13
  const maxAttachmentBytes = Number(limits.maxAttachmentBytes) || 12 * 1024 * 1024;
@@ -14,7 +15,7 @@
14
15
  const maxPreviewDataUrlChars = Number(limits.maxPreviewDataUrlChars) || 768 * 1024;
15
16
 
16
17
  let attachments = [];
17
- const pendingAttachmentKeys = new Set();
18
+ const pendingAttachmentKeys = new Map();
18
19
  let pendingAttachmentBytes = 0;
19
20
  let attachmentGeneration = 0;
20
21
  let attachmentRevision = 0;
@@ -87,8 +88,15 @@
87
88
  continue;
88
89
  }
89
90
  attachmentRevision += 1;
90
- pendingAttachmentKeys.add(dedupeKey);
91
+ pendingAttachmentKeys.set(dedupeKey, {
92
+ name: normalizeAttachmentName(file.name),
93
+ mimeType: String(file.type || ''),
94
+ size: fileSize,
95
+ loading: true
96
+ });
91
97
  pendingAttachmentBytes += fileSize;
98
+ renderComposerAttachments();
99
+ onChange();
92
100
  try {
93
101
  const dataUrl = await readFileAsDataUrl(file, tx);
94
102
  if (generation !== attachmentGeneration) return;
@@ -117,6 +125,8 @@
117
125
  if (generation === attachmentGeneration) {
118
126
  pendingAttachmentBytes = Math.max(0, pendingAttachmentBytes - fileSize);
119
127
  pendingAttachmentKeys.delete(dedupeKey);
128
+ renderComposerAttachments();
129
+ onChange();
120
130
  }
121
131
  }
122
132
  }
@@ -154,11 +164,13 @@
154
164
  if (!strip) {
155
165
  return;
156
166
  }
157
- strip.hidden = attachments.length === 0;
158
- renderAttachmentPreviewList(attachments, strip, {
167
+ const visibleAttachments = [...attachments, ...pendingAttachmentKeys.values()];
168
+ strip.hidden = visibleAttachments.length === 0;
169
+ renderAttachmentPreviewList(visibleAttachments, strip, {
159
170
  root: getPanel() || document.body,
160
171
  tr,
161
172
  tx,
173
+ icons,
162
174
  removable: true,
163
175
  onRemove: removeAttachment
164
176
  });
@@ -256,8 +268,8 @@
256
268
  }
257
269
 
258
270
  // v1.8.0 C4: survive a page refresh. Only small attachments persist
259
- // (single base64 <= 1MB, max 3) — oversized ones are dropped on restore
260
- // rather than bloating chrome.storage and every debounced saveState.
271
+ // (single base64 <= 1MB, max 3). Omitted names are saved separately so
272
+ // recovery can disclose files that need reattaching without saving bytes.
261
273
  const PERSIST_MAX_CONTENT_CHARS = 1024 * 1024;
262
274
  const PERSIST_MAX_COUNT = 3;
263
275
 
@@ -292,6 +304,15 @@
292
304
  return attachments.length;
293
305
  }
294
306
 
307
+ function getOmittedAttachmentNames() {
308
+ let persistedCount = 0;
309
+ return attachments.filter(attachment => {
310
+ if ((attachment.contentBase64 || '').length > PERSIST_MAX_CONTENT_CHARS || persistedCount >= PERSIST_MAX_COUNT) return true;
311
+ persistedCount += 1;
312
+ return false;
313
+ }).map(attachment => attachment.name);
314
+ }
315
+
295
316
  // Only a just-rejected local-skill submission uses this in-memory recovery.
296
317
  // It keeps the normal composer limits, without durable-draft truncation.
297
318
  function restoreSubmittedAttachments(value, expectedRevision) {
@@ -313,6 +334,8 @@
313
334
 
314
335
  return {
315
336
  serializePersistableAttachments,
337
+ getOmittedAttachmentNames,
338
+ hasPendingReads: () => pendingAttachmentKeys.size > 0,
316
339
  restorePersistedAttachments,
317
340
  restoreSubmittedAttachments,
318
341
  getRevision: () => attachmentRevision,
@@ -333,6 +356,7 @@
333
356
  root: getPanel() || document.body,
334
357
  tr,
335
358
  tx,
359
+ icons,
336
360
  ...options
337
361
  }),
338
362
  clear,
@@ -408,6 +432,12 @@
408
432
  .slice(0, 180) || '';
409
433
  }
410
434
 
435
+ function setCloseGlyph(button, icons) {
436
+ const glyph = icons?.svg?.('close', 'codex-close-glyph');
437
+ if (glyph) button.innerHTML = glyph;
438
+ else button.textContent = '×';
439
+ }
440
+
411
441
  function renderAttachmentPreviewList(attachments = [], container, options = {}) {
412
442
  if (!container) return;
413
443
  container.replaceChildren();
@@ -419,6 +449,10 @@
419
449
  const card = document.createElement('div');
420
450
  card.className = 'codex-attachment-preview-card';
421
451
  card.dataset.kind = isImage ? 'image' : 'file';
452
+ if (attachment.loading) {
453
+ card.dataset.loading = 'true';
454
+ card.setAttribute('aria-busy', 'true');
455
+ }
422
456
  card.title = label + ' · ' + formatFileSize(attachment.size);
423
457
  if (isImage) {
424
458
  const preview = document.createElement('button');
@@ -447,15 +481,17 @@
447
481
  if (!isImage) {
448
482
  const size = document.createElement('span');
449
483
  size.className = 'codex-attachment-preview-size';
450
- size.textContent = formatFileSize(attachment.size);
484
+ size.textContent = attachment.loading
485
+ ? (options.tx ? options.tx('Loading…', '读取中…') : 'Loading…')
486
+ : formatFileSize(attachment.size);
451
487
  meta.append(size);
452
488
  }
453
489
  card.append(meta);
454
- if (options.removable) {
490
+ if (options.removable && !attachment.loading) {
455
491
  const remove = document.createElement('button');
456
492
  remove.type = 'button';
457
493
  remove.className = 'codex-attachment-preview-remove';
458
- remove.textContent = '×';
494
+ setCloseGlyph(remove, options.icons);
459
495
  const removeLabel = options.tx ? options.tx('Remove ' + label, '移除 ' + label) : 'Remove ' + label;
460
496
  remove.title = removeLabel;
461
497
  remove.setAttribute('aria-label', removeLabel);
@@ -486,7 +522,7 @@
486
522
  const close = document.createElement('button');
487
523
  close.type = 'button';
488
524
  close.className = 'codex-attachment-preview-dialog-close';
489
- close.textContent = '×';
525
+ setCloseGlyph(close, options.icons);
490
526
  close.title = options.tr ? options.tr('close') : 'Close';
491
527
  close.setAttribute('aria-label', options.tr ? options.tr('close') : 'Close');
492
528
  const image = document.createElement('img');
@@ -13,6 +13,7 @@
13
13
  attachmentController: options.attachmentController || null,
14
14
  listeners: []
15
15
  };
16
+ const icon = name => options.icons?.svg(name) || '';
16
17
 
17
18
  container.innerHTML = `
18
19
  <div class="codex-pending-inputs" data-pending-inputs hidden></div>
@@ -20,9 +21,9 @@
20
21
  <div class="codex-attachment-strip codex-attachment-preview-list" data-attachment-strip hidden></div>
21
22
  <div class="codex-composer-selection" data-composer-selection hidden></div>
22
23
  <div class="codex-composer-skill-context" data-composer-skill-context hidden>
23
- <span class="codex-composer-skill-icon" aria-hidden="true">◇</span>
24
+ <span class="codex-composer-skill-icon" aria-hidden="true">${icon('sparkle')}</span>
24
25
  <span data-composer-skill-label></span>
25
- <button type="button" data-composer-skill-clear title="Clear skill" aria-label="Clear skill">×</button>
26
+ <button type="button" data-composer-skill-clear title="Clear skill" aria-label="Clear skill">${icon('close')}</button>
26
27
  </div>
27
28
  <textarea data-task rows="3" placeholder="Ask Codex anything. Type @ to add context"></textarea>
28
29
  <div class="codex-context-summary" data-context-summary hidden></div>
@@ -60,8 +61,43 @@
60
61
  </div>
61
62
  </div>
62
63
  <div class="codex-composer-toolbar">
63
- <button type="button" data-add-context title="Add @ context" aria-label="Add @ context" aria-expanded="false">+</button>
64
+ <button type="button" data-add-context title="Add @ context" aria-label="Add @ context" aria-expanded="false">${icon('plus')}</button>
64
65
  <div class="codex-model-config" data-model-config>
66
+ <div class="codex-context-usage" data-context-usage data-unknown="true">
67
+ <button type="button" class="codex-context-usage-button" data-context-usage-toggle aria-expanded="false">
68
+ <svg class="codex-context-usage-ring" viewBox="0 0 16 16" aria-hidden="true" focusable="false">
69
+ <circle class="codex-context-usage-track" cx="8" cy="8" r="6.4" pathLength="100"></circle>
70
+ <circle class="codex-context-usage-fill" cx="8" cy="8" r="2.55" pathLength="100"></circle>
71
+ <circle class="codex-context-usage-orbit" cx="8" cy="8" r="6.4" pathLength="100"></circle>
72
+ </svg>
73
+ </button>
74
+ <div class="codex-context-usage-popover" data-context-usage-popover role="region" tabindex="0" hidden>
75
+ <div class="codex-context-usage-head">
76
+ <svg class="codex-context-usage-empty-ring" data-context-usage-empty-ring viewBox="0 0 26 26" aria-hidden="true" focusable="false"><circle cx="13" cy="13" r="10.5"></circle><circle class="codex-context-usage-empty-dot" cx="13" cy="13" r="2"></circle></svg>
77
+ <div class="codex-context-usage-head-text">
78
+ <div class="codex-context-usage-title" data-context-usage-title></div>
79
+ <div class="codex-context-usage-amount" data-context-usage-amount></div>
80
+ </div>
81
+ <div class="codex-context-usage-figure" data-context-usage-figure>
82
+ <span class="codex-context-usage-spinner" data-context-usage-spinner hidden>${icon('spin')}</span>
83
+ <span class="codex-context-usage-chip" data-context-usage-chip hidden></span>
84
+ <span class="codex-context-usage-percent" data-context-usage-percent-wrap><span data-context-usage-percent></span><small data-context-usage-percent-sign>%</small></span>
85
+ <span class="codex-context-usage-level" data-context-usage-level hidden></span>
86
+ </div>
87
+ </div>
88
+ <div class="codex-context-usage-note" data-context-usage-note hidden>${icon('compact')}<span data-context-usage-note-text></span></div>
89
+ <div class="codex-context-usage-bar" data-context-usage-bar aria-hidden="true"><i data-seg="cached"></i><i data-seg="input"></i><i data-seg="output"></i></div>
90
+ <div class="codex-context-usage-legend" data-context-usage-breakdown hidden>
91
+ <div class="codex-context-usage-row"><i class="codex-context-usage-swatch" data-seg="input"></i><span data-context-usage-input-label></span><b data-context-usage-input></b><em data-context-usage-input-share></em></div>
92
+ <div class="codex-context-usage-row" data-sub data-context-usage-cache><i class="codex-context-usage-swatch" data-seg="cached"></i><span data-context-usage-cached-label></span><b data-context-usage-cached></b><em data-context-usage-cache-rate></em></div>
93
+ <div class="codex-context-usage-row"><i class="codex-context-usage-swatch" data-seg="output"></i><span data-context-usage-output-label></span><b data-context-usage-output></b><em data-context-usage-output-share></em></div>
94
+ </div>
95
+ <div class="codex-context-usage-foot" data-context-usage-foot>
96
+ <span data-context-usage-reported hidden></span>
97
+ <button type="button" class="codex-context-usage-compact" data-context-usage-compact>${icon('compact')}<span data-context-usage-compact-label></span></button>
98
+ </div>
99
+ </div>
100
+ </div>
65
101
  <button type="button" class="codex-model-config-button" data-model-config-toggle aria-haspopup="menu" aria-expanded="false">
66
102
  <span class="codex-model-speed-indicator" data-speed-indicator hidden aria-hidden="true">
67
103
  <svg viewBox="0 0 16 16" focusable="false">
@@ -99,7 +135,10 @@
99
135
  </div>
100
136
  </div>
101
137
  </div>
102
- <button type="submit" data-run data-action="send" title="Send" aria-label="Send">↑</button>
138
+ <button type="submit" data-run data-action="send" title="Send" aria-label="Send">
139
+ <span class="codex-run-glyph" data-run-glyph="send">${icon('send')}</span>
140
+ <span class="codex-run-glyph" data-run-glyph="cancel">${icon('stop')}</span>
141
+ </button>
103
142
  </div>
104
143
  <div class="codex-slash-menu" data-slash-menu hidden>
105
144
  <button type="button" data-slash-command="install-skill" data-slash-command-kind="installer">
@@ -119,9 +158,9 @@
119
158
  </svg>
120
159
  <span class="codex-context-sync-copy" data-context-sync-copy></span>
121
160
  </span>
122
- <button type="button" class="codex-attach-button" data-attach-file title="Attach files (PDF, images)" aria-label="Attach files (PDF, images)">⌲</button>
161
+ <button type="button" class="codex-attach-button" data-attach-file title="Attach files (PDF, images)" aria-label="Attach files (PDF, images)">${icon('attach')}</button>
123
162
  <input type="file" data-attach-input multiple hidden>
124
- <button type="button" data-context-refresh title="Refresh file list" aria-label="Refresh file list">↻</button>
163
+ <button type="button" data-context-refresh title="Refresh file list" aria-label="Refresh file list">${icon('refresh')}</button>
125
164
  </span>
126
165
  </div>
127
166
  <div class="codex-context-selection" data-context-selection></div>
@@ -136,6 +175,7 @@
136
175
 
137
176
  return {
138
177
  setRunning: running => setRunning(instance, running),
178
+ setSessionTransition: pending => setSessionTransition(instance, pending),
139
179
  setModels: models => setModels(instance, models),
140
180
  setMode: mode => setMode(instance, mode),
141
181
  focus: () => focus(instance),
@@ -157,14 +197,31 @@
157
197
  bind(instance, form, 'drop', event => instance.callbacks.onDrop?.(event));
158
198
  bind(instance, instance.container.querySelector('[data-run]'), 'click', event => {
159
199
  event.preventDefault();
160
- if (instance.callbacks.isRunning?.() && !String(task?.value || '').trim()) {
200
+ if (instance.container.querySelector('[data-run]')?.dataset.action === 'cancel') {
161
201
  instance.callbacks.onCancel?.();
162
202
  return;
163
203
  }
164
204
  form?.requestSubmit?.();
165
205
  });
166
206
  bind(instance, task, 'keydown', event => instance.callbacks.onTaskKeydown?.(event));
167
- bind(instance, task, 'input', event => instance.callbacks.onTaskInput?.(event));
207
+ bind(instance, task, 'input', event => {
208
+ if (acceptTaskInput(instance, task)) instance.callbacks.onTaskInput?.(event);
209
+ });
210
+ bind(instance, task, 'compositionstart', () => {
211
+ const composition = { scope: instance.callbacks.getTaskScope?.() };
212
+ composition.done = new Promise(resolve => { composition.resolve = resolve; });
213
+ instance.composition = composition;
214
+ });
215
+ bind(instance, task, 'compositionend', event => {
216
+ const composition = instance.composition;
217
+ if (acceptTaskInput(instance, task)) instance.callbacks.onInputChange?.(event);
218
+ // Chrome may emit the final input after compositionend. Keep its original
219
+ // owner until that event batch has finished, before a session can change.
220
+ if (composition) setTimeout(() => {
221
+ if (instance.composition === composition) instance.composition = null;
222
+ composition.resolve();
223
+ }, 0);
224
+ });
168
225
  bind(instance, instance.container.querySelector('[data-slash-menu]'), 'click', event => instance.callbacks.onSlashMenuClick?.(event));
169
226
  bind(instance, instance.container.querySelector('[data-composer-skill-clear]'), 'click', () => instance.callbacks.onClearSkillInvocation?.());
170
227
  bind(instance, instance.container.querySelector('[data-add-context]'), 'click', () => instance.callbacks.onAddContext?.());
@@ -181,8 +238,11 @@
181
238
  }
182
239
  for (const selector of ['[data-model]', '[data-reasoning]', '[data-speed]', '[data-mode]', '[data-task]', '[data-require-reviewing]', '[data-auto-recompile]']) {
183
240
  const element = instance.container.querySelector(selector);
184
- bind(instance, element, 'change', event => instance.callbacks.onInputChange?.(event));
185
- bind(instance, element, 'input', event => instance.callbacks.onInputChange?.(event));
241
+ const changed = event => {
242
+ if (element !== task || acceptTaskInput(instance, task)) instance.callbacks.onInputChange?.(event);
243
+ };
244
+ bind(instance, element, 'change', changed);
245
+ bind(instance, element, 'input', changed);
186
246
  }
187
247
  }
188
248
 
@@ -210,6 +270,25 @@
210
270
  }
211
271
  }
212
272
 
273
+ function acceptTaskInput(instance, task) {
274
+ if (instance.composition && instance.composition.scope !== instance.callbacks.getTaskScope?.()) {
275
+ task.value = instance.callbacks.getTaskValue?.() || '';
276
+ return false;
277
+ }
278
+ return !instance.sessionTransition || Boolean(instance.composition);
279
+ }
280
+
281
+ function setSessionTransition(instance, pending) {
282
+ const task = instance.container.querySelector('[data-task]');
283
+ if (pending && !instance.sessionTransition) instance.previousReadOnly = task?.readOnly === true;
284
+ instance.sessionTransition = pending;
285
+ const composition = instance.composition;
286
+ if (pending && composition) task?.blur();
287
+ if (task) task.readOnly = pending || instance.previousReadOnly === true;
288
+ setRunning(instance, Boolean(instance.callbacks.isRunning?.()));
289
+ return pending ? composition?.done : undefined;
290
+ }
291
+
213
292
  function setRunning(target, running) {
214
293
  const instance = target?._instance || target;
215
294
  const runButton = instance?.container?.querySelector('[data-run]');
@@ -217,14 +296,14 @@
217
296
  return;
218
297
  }
219
298
  const hasInput = Boolean(String(instance?.container?.querySelector('[data-task]')?.value || '').trim());
220
- const action = running && !hasInput ? 'cancel' : 'send';
299
+ const action = running && (!hasInput || instance.sessionTransition) ? 'cancel' : 'send';
221
300
  const label = action === 'cancel'
222
301
  ? t(instance, 'cancelRun')
223
302
  : running
224
303
  ? t(instance, 'queueNextInput')
225
304
  : t(instance, 'send');
226
305
  runButton.dataset.action = action;
227
- runButton.disabled = action === 'send' && !hasInput;
306
+ runButton.disabled = action === 'send' && (!hasInput || instance.sessionTransition);
228
307
  runButton.title = label;
229
308
  runButton.setAttribute('aria-label', label);
230
309
  }
@@ -293,6 +372,7 @@
293
372
  }
294
373
 
295
374
  function destroy(instance) {
375
+ instance.composition?.resolve();
296
376
  instance.menuResizeObserver?.disconnect();
297
377
  for (const { target, type, listener } of instance.listeners.splice(0)) {
298
378
  target.removeEventListener?.(type, listener);