codex-overleaf-link 2.5.1 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) 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 +78 -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 +11 -2
  9. package/extension/src/content/composerPanel.js +92 -12
  10. package/extension/src/content/contentRuntime.js +920 -276
  11. package/extension/src/content/contextTray.js +8 -2
  12. package/extension/src/content/contextUsageView.js +267 -0
  13. package/extension/src/content/diagnosticsPanel.js +18 -0
  14. package/extension/src/content/generated/content.bundle.js +4496 -924
  15. package/extension/src/content/generated/content.bundle.meta.json +6 -3
  16. package/extension/src/content/legacyGlobalRegistry.js +4 -0
  17. package/extension/src/content/localSkillsPanel.js +2 -2
  18. package/extension/src/content/markdownDomRenderer.js +5 -3
  19. package/extension/src/content/markdownText.js +42 -5
  20. package/extension/src/content/modelPicker.js +1 -0
  21. package/extension/src/content/nativeCompatibilityController.js +29 -10
  22. package/extension/src/content/otWarmMirror.js +1 -1
  23. package/extension/src/content/pageBridgeClient.js +2 -2
  24. package/extension/src/content/panelMaintenance.js +6 -2
  25. package/extension/src/content/panelRenderer.js +51 -27
  26. package/extension/src/content/pendingInputView.js +1 -1
  27. package/extension/src/content/projectSettingsCoordinator.js +30 -6
  28. package/extension/src/content/providerSettingsDialog.js +2 -1
  29. package/extension/src/content/recentProjects.js +4 -3
  30. package/extension/src/content/runActivitySummary.js +122 -14
  31. package/extension/src/content/runCompletionMeta.js +194 -6
  32. package/extension/src/content/runController.js +2 -1
  33. package/extension/src/content/runFailureNotice.js +8 -1
  34. package/extension/src/content/runPresence.js +18 -8
  35. package/extension/src/content/runQueueScheduler.js +9 -5
  36. package/extension/src/content/runRecoveryActions.js +2 -1
  37. package/extension/src/content/runResultActions.js +22 -20
  38. package/extension/src/content/runReviewController.js +94 -35
  39. package/extension/src/content/runTimelineView.js +224 -25
  40. package/extension/src/content/scopedPersistencePanelState.js +92 -23
  41. package/extension/src/content/sensitivePreflight.js +14 -1
  42. package/extension/src/content/sessionManager.js +49 -3
  43. package/extension/src/content/sessionMenuView.js +54 -18
  44. package/extension/src/content/sessionPersistence.js +25 -5
  45. package/extension/src/content/settingsPanel.js +15 -2
  46. package/extension/src/content/settingsWorkbench.js +127 -89
  47. package/extension/src/content/sharedSessionViewSync.js +4 -3
  48. package/extension/src/content/subagentActivityView.js +23 -7
  49. package/extension/src/content/updateNotice.js +60 -6
  50. package/extension/src/content/writebackOrchestrator.js +35 -22
  51. package/extension/src/page/compileBridge.js +193 -180
  52. package/extension/src/page/overleafCapabilities.js +4 -1
  53. package/extension/src/page/textCoordinates.js +17 -1
  54. package/extension/src/page/textFileCreator.js +9 -1
  55. package/extension/src/page/writebackRouter.js +24 -77
  56. package/extension/src/pageBridge.js +107 -66
  57. package/extension/src/popup.js +1 -1
  58. package/extension/src/shared/agentTranscript.js +350 -17
  59. package/extension/src/shared/auditRecords.js +2 -0
  60. package/extension/src/shared/compatibility.js +1 -1
  61. package/extension/src/shared/compileAdapter.js +58 -17
  62. package/extension/src/shared/composerAttachmentDraft.js +6 -1
  63. package/extension/src/shared/contextUsage.js +125 -0
  64. package/extension/src/shared/failureReasons.js +42 -3
  65. package/extension/src/shared/i18n.js +299 -29
  66. package/extension/src/shared/lineReferences.js +38 -31
  67. package/extension/src/shared/pageRpcContract.js +1 -1
  68. package/extension/src/shared/panelIcons.js +39 -0
  69. package/extension/src/shared/reviewing.js +130 -1
  70. package/extension/src/shared/runActivityModel.js +70 -4
  71. package/extension/src/shared/runExecutionSnapshot.js +13 -0
  72. package/extension/src/shared/sessionState.js +63 -4
  73. package/extension/src/shared/sessionTitle.js +14 -1
  74. package/extension/src/shared/sharedSessionBridge.js +81 -13
  75. package/extension/src/shared/staleGuard.js +72 -63
  76. package/extension/src/shared/storageDb.js +35 -3
  77. package/extension/src/shared/storageMigration.js +7 -1
  78. package/extension/src/shared/storageRunActions.js +62 -8
  79. package/extension/src/shared/undoOperations.js +126 -28
  80. package/extension/src/shared/updateRuntimeIdentity.js +16 -2
  81. package/extension/src/shared/writebackSettlement.js +21 -3
  82. package/extension/styles/panel.css +1106 -490
  83. package/native-host/src/bootstrapUpdate.js +52 -16
  84. package/native-host/src/chatBridgeRequest.js +60 -6
  85. package/native-host/src/chatBridgeResponse.js +10 -1
  86. package/native-host/src/chatCompletionsBridge.js +29 -2
  87. package/native-host/src/codexPromptAssembly.js +86 -118
  88. package/native-host/src/codexSessionRunner.js +126 -17
  89. package/native-host/src/providerContextError.js +20 -0
  90. package/native-host/src/taskRunnerRuntime.js +18 -6
  91. package/native-host/src/textPatch.js +473 -377
  92. package/native-host/src/updateManager.js +120 -15
  93. 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;
@@ -159,6 +160,7 @@
159
160
  root: getPanel() || document.body,
160
161
  tr,
161
162
  tx,
163
+ icons,
162
164
  removable: true,
163
165
  onRemove: removeAttachment
164
166
  });
@@ -333,6 +335,7 @@
333
335
  root: getPanel() || document.body,
334
336
  tr,
335
337
  tx,
338
+ icons,
336
339
  ...options
337
340
  }),
338
341
  clear,
@@ -408,6 +411,12 @@
408
411
  .slice(0, 180) || '';
409
412
  }
410
413
 
414
+ function setCloseGlyph(button, icons) {
415
+ const glyph = icons?.svg?.('close', 'codex-close-glyph');
416
+ if (glyph) button.innerHTML = glyph;
417
+ else button.textContent = '×';
418
+ }
419
+
411
420
  function renderAttachmentPreviewList(attachments = [], container, options = {}) {
412
421
  if (!container) return;
413
422
  container.replaceChildren();
@@ -455,7 +464,7 @@
455
464
  const remove = document.createElement('button');
456
465
  remove.type = 'button';
457
466
  remove.className = 'codex-attachment-preview-remove';
458
- remove.textContent = '×';
467
+ setCloseGlyph(remove, options.icons);
459
468
  const removeLabel = options.tx ? options.tx('Remove ' + label, '移除 ' + label) : 'Remove ' + label;
460
469
  remove.title = removeLabel;
461
470
  remove.setAttribute('aria-label', removeLabel);
@@ -486,7 +495,7 @@
486
495
  const close = document.createElement('button');
487
496
  close.type = 'button';
488
497
  close.className = 'codex-attachment-preview-dialog-close';
489
- close.textContent = '×';
498
+ setCloseGlyph(close, options.icons);
490
499
  close.title = options.tr ? options.tr('close') : 'Close';
491
500
  close.setAttribute('aria-label', options.tr ? options.tr('close') : 'Close');
492
501
  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);