codex-overleaf-link 2.6.0 → 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 (58) hide show
  1. package/README.md +9 -9
  2. package/README.zh-CN.md +9 -9
  3. package/extension/bootstrap/manifest.template.json +1 -1
  4. package/extension/src/background.js +70 -0
  5. package/extension/src/content/composerAttachments.js +35 -8
  6. package/extension/src/content/contentRuntime.js +200 -51
  7. package/extension/src/content/diagnosticsController.js +77 -34
  8. package/extension/src/content/diagnosticsExport.js +1 -1
  9. package/extension/src/content/diagnosticsPanel.js +129 -42
  10. package/extension/src/content/generated/content.bundle.js +1283 -331
  11. package/extension/src/content/generated/content.bundle.meta.json +3 -3
  12. package/extension/src/content/localSkillsPanel.js +4 -2
  13. package/extension/src/content/markdownText.js +11 -9
  14. package/extension/src/content/modelPicker.js +28 -19
  15. package/extension/src/content/nativeCompatibilityController.js +23 -9
  16. package/extension/src/content/pageBridgeClient.js +5 -5
  17. package/extension/src/content/panelMaintenance.js +4 -0
  18. package/extension/src/content/panelRenderer.js +4 -1
  19. package/extension/src/content/providerSettingsDialog.js +26 -18
  20. package/extension/src/content/recentProjects.js +26 -16
  21. package/extension/src/content/runQueueScheduler.js +19 -9
  22. package/extension/src/content/runResultActions.js +51 -4
  23. package/extension/src/content/runReviewController.js +9 -3
  24. package/extension/src/content/scopedPersistencePanelState.js +34 -25
  25. package/extension/src/content/selectionContextView.js +30 -3
  26. package/extension/src/content/sharedSessionViewSync.js +9 -1
  27. package/extension/src/content/writebackOrchestrator.js +266 -50
  28. package/extension/src/content/writingStyleSettings.js +70 -17
  29. package/extension/src/page/overleafEditor.js +12 -3
  30. package/extension/src/page/overleafProjectSnapshot.js +56 -3
  31. package/extension/src/page/snapshotRouter.js +75 -66
  32. package/extension/src/page/treeOperations.js +200 -19
  33. package/extension/src/page/writebackRouter.js +152 -40
  34. package/extension/src/pageBridge.js +17 -7
  35. package/extension/src/shared/agentTranscript.js +23 -2
  36. package/extension/src/shared/auditRecords.js +1 -0
  37. package/extension/src/shared/compatibility.js +1 -1
  38. package/extension/src/shared/composerAttachmentDraft.js +22 -7
  39. package/extension/src/shared/failureReasons.js +15 -0
  40. package/extension/src/shared/i18n.js +16 -0
  41. package/extension/src/shared/pageRpcContract.js +1 -1
  42. package/extension/src/shared/projectFiles.js +2 -1
  43. package/extension/src/shared/scopedPersistenceBrowserAdapter.js +34 -0
  44. package/extension/src/shared/staleGuard.js +58 -0
  45. package/extension/src/shared/storageMigration.js +104 -1
  46. package/extension/styles/panel.css +191 -63
  47. package/native-host/src/codexSessionRunner.js +1 -0
  48. package/native-host/src/debugLog.js +27 -3
  49. package/native-host/src/index.js +42 -11
  50. package/native-host/src/localSkills.js +1 -1
  51. package/native-host/src/mirrorWorkspace.js +47 -3
  52. package/native-host/src/nativeEnvironment.js +27 -5
  53. package/native-host/src/providerRuntime.js +1 -0
  54. package/native-host/src/skills/reviewer-response/SKILL.md +65 -0
  55. package/native-host/src/taskRunnerRuntime.js +11 -3
  56. package/native-host/src/updateManager.js +24 -6
  57. package/native-host/src/writingStyleRuntime.js +1 -1
  58. package/package.json +1 -1
@@ -2,9 +2,9 @@
2
2
  "schemaVersion": 1,
3
3
  "entry": "extension/entries/content-entry.mjs",
4
4
  "output": "extension/src/content/generated/content.bundle.js",
5
- "sourceDigest": "0cf851b033afb0885464fd86506f423f35fe82d6da8c1f1644012350cab0ddc4",
6
- "outputDigest": "0abf629ca414d00be2ab6bbde553c66a7f66ad2d0a4551bb272f3a5948a7d461",
7
- "byteLength": 3387559,
5
+ "sourceDigest": "91739cf894fed56c2880986120922d3b54bb423ab2d116705385da1d0fe518be",
6
+ "outputDigest": "a52bf46e64085f1a64339769d64ab59246323c3a51c65851f3385750727b0624",
7
+ "byteLength": 3443627,
8
8
  "inputs": [
9
9
  "extension/entries/content-entry.mjs",
10
10
  "extension/src/content/activeTurnControl.js",
@@ -204,7 +204,8 @@
204
204
  copy.append(text);
205
205
  const descriptions = {
206
206
  'annotated-rewrite': tx('Guided editing for document revisions.', '用于文稿修订的编辑流程。'),
207
- 'parallel-subagents': tx('Split independent subtasks across multiple agents.', '将相互独立的子任务分配给多个 agent。')
207
+ 'parallel-subagents': tx('Split independent subtasks across multiple agents.', '将相互独立的子任务分配给多个 agent。'),
208
+ 'reviewer-response': tx('Map reviewer comments to evidence, edits and reply drafts.', '把审稿意见对应到论文证据、修改和回复草稿。')
208
209
  };
209
210
  const description = (!local && descriptions[id])
210
211
  || (typeof skill.description === 'string' ? skill.description.replace(/\s+/g, ' ').trim() : '');
@@ -249,7 +250,8 @@
249
250
  if (sourceLabel) sourceLabel.textContent = sourceText();
250
251
  toggle.setAttribute('aria-label', accessibleSkillName + ': ' + tr('codexOverleafSkillEnableToggle'));
251
252
  const label = !local && id === 'annotated-rewrite' ? tx('Guided editing for document revisions.', '用于文稿修订的编辑流程。')
252
- : !local && id === 'parallel-subagents' ? tx('Split independent subtasks across multiple agents.', '将相互独立的子任务分配给多个 agent。') : '';
253
+ : !local && id === 'parallel-subagents' ? tx('Split independent subtasks across multiple agents.', '将相互独立的子任务分配给多个 agent。')
254
+ : !local && id === 'reviewer-response' ? tx('Map reviewer comments to evidence, edits and reply drafts.', '把审稿意见对应到论文证据、修改和回复草稿。') : '';
253
255
  const help = copy.querySelector('.codex-local-skill-description');
254
256
  if (label && help) { help.textContent = label; help.title = label; }
255
257
  };
@@ -70,6 +70,15 @@
70
70
  macros: getMathMacros(), renderText: buildMarkdownInlineNodesWithoutMath });
71
71
  }
72
72
 
73
+ function buildParsedTextNodes(value) {
74
+ const source = String(value || '');
75
+ if (!MathText?.buildMathNodes) {
76
+ return buildLineReferenceInlineNodes(source);
77
+ }
78
+ return MathText.buildMathNodes(source, { document, katex: window.katex,
79
+ macros: getMathMacros(), renderText: buildLineReferenceInlineNodes });
80
+ }
81
+
73
82
  function buildMarkdownInlineNodesWithoutMath(value) {
74
83
  const source = String(value || '');
75
84
  const nodes = [];
@@ -514,15 +523,8 @@
514
523
  document,
515
524
  markdownIt: runtimeRoot.markdownit,
516
525
  mathText: runtimeRoot.CodexOverleafMathText,
517
- renderTextNodes: text => buildMarkdownInlineNodes(text),
518
- renderLinkNodes: (label, href) => {
519
- const safeLabel = String(label || '');
520
- const target = String(href || '').trim();
521
- if (!/^https?:\/\//i.test(target) && !resolveMarkdownLineReference(safeLabel, target)) return buildMarkdownInlineNodes(safeLabel);
522
- return buildMarkdownInlineNodes(
523
- `[${safeLabel.replace(/]/g, '\\]')}](${target.replace(/\)/g, '\\)')})`
524
- );
525
- },
526
+ renderTextNodes: buildParsedTextNodes,
527
+ renderLinkNodes: buildMarkdownLinkNodes,
526
528
  renderCodeNodes: text => buildInlineCodeNodes(text),
527
529
  sanitizeText: sanitizeAssistantVisibleText,
528
530
  createMathNode: segment => runtimeRoot.CodexOverleafMathText.createMathNode(segment,
@@ -33,7 +33,7 @@
33
33
  retainSelectedModel
34
34
  } = Support;
35
35
 
36
- let modelDiscovery = { status: 'fallback', source: 'fallback', fetchedAt: '' };
36
+ let modelDiscovery = { status: 'fallback', source: 'fallback', fetchedAt: '', provisional: true };
37
37
  let modelLoadGeneration = 0;
38
38
 
39
39
  function getModelDiscovery() {
@@ -83,24 +83,21 @@
83
83
  const modelSelect = getPanel()?.querySelector('[data-model]');
84
84
  const reasoningSelect = getPanel()?.querySelector('[data-reasoning]');
85
85
  const speedSelect = getPanel()?.querySelector('[data-speed]');
86
- let changedInput = null;
87
-
88
- if (choice.dataset.reasoningChoice && reasoningSelect) {
89
- reasoningSelect.value = choice.dataset.reasoningChoice;
90
- changedInput = reasoningSelect;
91
- } else if (choice.dataset.modelChoice && modelSelect) {
92
- modelSelect.value = choice.dataset.modelChoice;
93
- changedInput = modelSelect;
86
+ const changedInput = choice.dataset.reasoningChoice ? reasoningSelect
87
+ : choice.dataset.modelChoice ? modelSelect : choice.dataset.speedChoice ? speedSelect : null;
88
+ const value = choice.dataset.reasoningChoice || choice.dataset.modelChoice || choice.dataset.speedChoice;
89
+ const option = Array.from(changedInput?.options || []).find(item => item.value === value);
90
+ if (!changedInput || changedInput.disabled || !option || option.disabled) return;
91
+
92
+ changedInput.value = value;
93
+ if (changedInput === modelSelect) {
94
94
  renderReasoningOptions(getRenderedModelEntries());
95
95
  renderSpeedOptions(getRenderedModelEntries());
96
- } else if (choice.dataset.speedChoice && speedSelect) {
97
- speedSelect.value = choice.dataset.speedChoice;
98
- changedInput = speedSelect;
99
96
  }
100
97
 
101
98
  renderModelConfigChoices();
102
99
  updateModelDisplay();
103
- await persistPanelInputs(changedInput ? { type: 'change', target: changedInput } : undefined);
100
+ await persistPanelInputs({ type: 'change', target: changedInput });
104
101
  }
105
102
  async function loadModelOptions(providerSelection, options = {}) {
106
103
  const generation = ++modelLoadGeneration;
@@ -136,7 +133,9 @@
136
133
  }
137
134
  const sourceModels = hasDiscoveredModels ? response.result.models : fallbackModels;
138
135
  const preserveBuiltin = (selection?.providerId || 'builtin') === 'builtin' && (getState()?.providerId || 'builtin') === 'builtin';
139
- const retainedSelectedModel = retainSelectedModel(sourceModels, currentSelectedModel, { preserveUnavailable: preserveBuiltin });
136
+ const retainedSelectedModel = retainSelectedModel(sourceModels, currentSelectedModel, { preserveUnavailable: preserveBuiltin })
137
+ || (responseProviderId && responseProviderId !== 'builtin'
138
+ ? retainSelectedModel(sourceModels, response.result?.defaultModelId) : '');
140
139
  const normalized = modelCatalog.normalizeDiscoveredModels({ models: sourceModels, selectedModel: retainedSelectedModel });
141
140
  if (normalized.usedFallback && response.result?.providerId && response.result.providerId !== 'builtin') {
142
141
  applyUnavailableModelOptions({ code: 'provider_model_catalog_invalid', message: 'The active provider returned no usable models.' });
@@ -238,6 +237,13 @@
238
237
  }
239
238
 
240
239
  const selectedId = normalizeModelOptionId(selectedModel);
240
+ if (options.provisionalSelected === true) {
241
+ models = [...(Array.isArray(models) ? models : [])];
242
+ if (selectedId && !models.some(model => normalizeModelOptionId(model?.id) === selectedId)) {
243
+ models.push({ id: selectedId, label: selectedId, unverified: true });
244
+ }
245
+ models = Support.preserveSelectedCapabilities(models, selectedId, getState(), true);
246
+ }
241
247
  modelSelect.textContent = '';
242
248
  modelSelect.disabled = false;
243
249
  let renderedSelected = false;
@@ -373,7 +379,8 @@
373
379
  list.append(createModelConfigChoice({
374
380
  value: option.value,
375
381
  label: formatReasoningEffortLabel(option.value, reasoningSelect.dataset.presentation),
376
- datasetName: 'reasoningChoice'
382
+ datasetName: 'reasoningChoice',
383
+ disabled: reasoningSelect.disabled || option.disabled
377
384
  }));
378
385
  }
379
386
  }
@@ -390,7 +397,8 @@
390
397
  const choice = createModelConfigChoice({
391
398
  value: option.value,
392
399
  label: option.textContent || option.value,
393
- datasetName: 'modelChoice'
400
+ datasetName: 'modelChoice',
401
+ disabled: modelSelect.disabled || option.disabled
394
402
  });
395
403
  choice.title = option.textContent || option.value;
396
404
  list.append(choice);
@@ -408,16 +416,17 @@
408
416
  const choice = createModelConfigChoice({
409
417
  value: option.value,
410
418
  label: formatSpeedTierLabel(option.value),
411
- datasetName: 'speedChoice'
419
+ datasetName: 'speedChoice',
420
+ disabled: speedSelect.disabled || option.disabled
412
421
  });
413
- choice.disabled = speedSelect.disabled && speedSelect.options.length <= 1;
414
422
  list.append(choice);
415
423
  }
416
424
  }
417
425
 
418
- function createModelConfigChoice({ value, label, datasetName }) {
426
+ function createModelConfigChoice({ value, label, datasetName, disabled = false }) {
419
427
  const button = document.createElement('button');
420
428
  button.type = 'button';
429
+ button.disabled = disabled;
421
430
  button.className = 'codex-model-config-choice';
422
431
  button.dataset[datasetName] = value;
423
432
  button.setAttribute('role', 'menuitemradio');
@@ -37,8 +37,10 @@
37
37
  try {
38
38
  compatibilityGate = await ensureForMethod(payload?.method, context);
39
39
  if (!compatibilityGate.ok) return compatibilityGate.response;
40
+ // Catalog reads belong to the selected session, not a retained cancelled run.
41
+ const independentCatalog = payload?.method === 'codex.models' || payload?.method === 'codex.providers.list';
40
42
  // Post-Undo cache bookkeeping must survive the ended run's cancellation.
41
- if (gatedMethods.has(payload?.method) && payload.method !== 'mirror.invalidate') {
43
+ if (gatedMethods.has(payload?.method) && payload.method !== 'mirror.invalidate' && !independentCatalog) {
42
44
  options.throwIfCancellationRequested?.();
43
45
  }
44
46
  context.assertCurrent?.();
@@ -147,7 +149,9 @@
147
149
  options.setDiagnosticsHealth?.(classification === 'update-available' ? 'warn' : 'fail');
148
150
  options.setBadge?.(renderer.headerEl, {
149
151
  type: 'update',
150
- tooltip: tx('Native host update available', 'Native host 可更新'),
152
+ tooltip: compatibility.status === 'native_unhealthy'
153
+ ? tx('Codex Unavailable', 'Codex 不可用')
154
+ : tx('Native host update available', 'Native host 可更新'),
151
155
  onClick: () => showUpdateGuidance(compatibility)
152
156
  });
153
157
  if (!compatibility?.native?.version) maybePromptFirstRunSetup();
@@ -191,7 +195,9 @@
191
195
  || compatibility.nativeVersion
192
196
  || native.version
193
197
  || tx('missing', '未安装');
194
- const command = resolveInstallCommand(compatibility);
198
+ const codexUnavailable = compatibility.status === 'native_unhealthy';
199
+ const resolvedCommand = resolveInstallCommand(compatibility);
200
+ const command = codexUnavailable ? '' : resolvedCommand;
195
201
  const releaseUrl = compatibility.releaseUrl || '';
196
202
  const overlay = options.document.createElement('div');
197
203
  overlay.className = 'codex-plugin-confirm';
@@ -201,7 +207,9 @@
201
207
  const nativeMissing = !native.version;
202
208
  overlay.setAttribute(
203
209
  'aria-label',
204
- nativeMissing
210
+ codexUnavailable
211
+ ? tx('Codex Unavailable', 'Codex 不可用')
212
+ : nativeMissing
205
213
  ? tx('Set up the native host', '安装 Native host')
206
214
  : tx('Native host update available', 'Native host 可更新')
207
215
  );
@@ -220,14 +228,19 @@
220
228
  brand.textContent = 'Codex Overleaf Link';
221
229
  const title = options.document.createElement('div');
222
230
  title.className = 'codex-plugin-confirm-title';
223
- title.textContent = nativeMissing
231
+ title.textContent = codexUnavailable
232
+ ? tx('Codex Unavailable', 'Codex 不可用')
233
+ : nativeMissing
224
234
  ? tx('One step left: install the native host', '还差一步:安装 Native host')
225
235
  : tx('Native host update available', 'Native host 可更新');
226
236
  titleWrap.append(brand, title);
227
237
  head.append(icon, titleWrap);
228
238
  const body = options.document.createElement('div');
229
239
  body.className = 'codex-plugin-confirm-body';
230
- body.textContent = [
240
+ body.textContent = codexUnavailable ? [
241
+ tx('The extension reached the native host, but local Codex was not found, so tasks cannot start.', '插件已经连上 native host,但没有找到本机 Codex,所以不能启动任务。'),
242
+ tx('Make sure codex works in Terminal, then reload the extension.', '请确认终端里可以运行 codex,然后重新加载扩展。')
243
+ ].join('\n\n') : [
231
244
  nativeMissing
232
245
  ? tx(
233
246
  `Extension v${extensionVersion} is ready; Codex still needs its local bridge to read and edit this project.`,
@@ -247,8 +260,8 @@
247
260
  const commandCode = options.document.createElement('code');
248
261
  commandCode.className = 'codex-native-guidance-command';
249
262
  commandCode.textContent = command;
250
- body.append(commandCode);
251
- if (releaseUrl) {
263
+ if (!codexUnavailable) body.append(commandCode);
264
+ if (!codexUnavailable && releaseUrl) {
252
265
  const link = options.document.createElement('a');
253
266
  link.className = 'codex-native-guidance-link';
254
267
  link.href = releaseUrl;
@@ -274,7 +287,8 @@
274
287
  close.className = 'codex-plugin-confirm-cancel';
275
288
  close.textContent = tx('Close', '关闭');
276
289
  close.addEventListener('click', () => overlay.remove());
277
- actions.append(close, copy);
290
+ actions.append(close);
291
+ if (!codexUnavailable) actions.append(copy);
278
292
  card.append(head, body, actions);
279
293
  overlay.append(card);
280
294
  overlay.addEventListener('click', event => {
@@ -10,21 +10,22 @@
10
10
  ['src/shared/projectFiles.js', 'codex-overleaf-project-files-script'],
11
11
  ['src/shared/compatibility.js', 'codex-overleaf-compatibility-page-script'],
12
12
  ['src/shared/pageRpcContract.js', 'codex-overleaf-page-rpc-contract-script', true],
13
- ['src/shared/staleGuard.js', 'codex-overleaf-stale-guard-script'],
13
+ ['src/shared/staleGuard.js', 'codex-overleaf-stale-guard-script', true],
14
+ ['src/shared/otText.js', 'codex-overleaf-ot-text-script'],
14
15
  ['src/shared/compileAdapter.js', 'codex-overleaf-compile-adapter-script'],
15
16
  ['src/shared/sensitiveScan.js', 'codex-overleaf-sensitive-scan-script'],
16
17
  ['src/shared/auditRecords.js', 'codex-overleaf-audit-records-script'],
17
18
  ['src/page/saveState.js', 'codex-overleaf-save-state-script', true],
18
19
  ['src/page/overleafCapabilities.js', 'codex-overleaf-capabilities-script', true],
19
20
  ['src/page/compileBridge.js', 'codex-overleaf-compile-bridge-script'],
20
- ['src/page/overleafEditor.js', 'codex-overleaf-editor-script'],
21
- ['src/page/overleafProjectSnapshot.js', 'codex-overleaf-project-snapshot-script'],
21
+ ['src/page/overleafEditor.js', 'codex-overleaf-editor-script', true],
22
+ ['src/page/overleafProjectSnapshot.js', 'codex-overleaf-project-snapshot-script', true],
22
23
  ['src/page/pageBridgeCapability.js', 'codex-overleaf-page-bridge-capability-script'],
23
24
  ['src/page/treeOperations.js', 'codex-overleaf-tree-operations-script', true],
24
25
  ['src/page/ownedDirectoryCleanup.js', 'codex-overleaf-owned-directory-cleanup-script', true],
25
26
  ['src/page/textFileCreator.js', 'codex-overleaf-text-file-creator-script', true],
26
27
  ['src/page/binaryAssetUploader.js', 'codex-overleaf-binary-asset-uploader-script', true],
27
- ['src/page/snapshotRouter.js', 'codex-overleaf-snapshot-router-script'],
28
+ ['src/page/snapshotRouter.js', 'codex-overleaf-snapshot-router-script', true],
28
29
  ['src/page/referenceProjects.js', 'codex-overleaf-reference-projects-script', true],
29
30
  ['src/page/fileMover.js', 'codex-overleaf-file-mover-script', true],
30
31
  ['src/page/writeGuard.js', 'codex-overleaf-write-guard-script', true],
@@ -39,7 +40,6 @@
39
40
  ['src/pageBridge.js', 'codex-overleaf-page-bridge-script', true]
40
41
  ]);
41
42
  const OPTIONAL_OT_SCRIPTS = Object.freeze([
42
- ['src/shared/otText.js', 'codex-overleaf-ot-text-script'],
43
43
  ['src/page/overleafRealtimeObserver.js', 'codex-overleaf-realtime-observer-script']
44
44
  ]);
45
45
 
@@ -189,6 +189,10 @@
189
189
  showPluginToast(tx('Nothing to refill: the original task text is unavailable.', '无法回填:原任务文本不可用。'));
190
190
  return;
191
191
  }
192
+ if (input.value.trim() && input.value.trim() !== task.trim()) {
193
+ showPluginToast(tx('Kept your draft. Clear the composer, then try again to refill this task.', '已保留你的草稿。清空输入框后再试一次,即可回填这条任务。'));
194
+ return;
195
+ }
192
196
  prepareRetryReplacement?.(run || null, task);
193
197
  input.value = task;
194
198
  getState().task = task;
@@ -228,7 +228,10 @@
228
228
  return compact;
229
229
  }
230
230
 
231
- function setVisible(panelEl, visible) {
231
+ function setVisible(target, visible) {
232
+ const instance = target?._instance;
233
+ const panelEl = instance?.panelEl || target;
234
+ if (visible && instance) updateCompactMode(instance);
232
235
  panelEl?.classList?.toggle('is-open', Boolean(visible));
233
236
  document.documentElement.classList.toggle(ROOT_CLASS, Boolean(visible));
234
237
  const launcherEl = panelEl?.ownerDocument?.getElementById?.('codex-overleaf-launcher');
@@ -613,18 +613,7 @@
613
613
  actions.innerHTML = instance.embedded ? '' : `<button type="button" class="codex-provider-secondary-button" data-provider-action="close">${escapeHtml(tx('Close', '关闭'))}</button>`;
614
614
  return;
615
615
  }
616
- const active = provider.id && provider.id === getCurrentProjectProviderId(instance);
617
- const isNew = !provider.id;
618
- const destinationChanged = active && getCurrentBaseUrl(instance) !== provider.endpointDisclosureBaseUrl;
619
- const actionState = getFooterActionState({
620
- isNew,
621
- active,
622
- dirty: instance.dirty,
623
- destinationChanged,
624
- canSave: canSaveCurrentDraft(instance),
625
- hasProject: instance.callbacks.hasCurrentProject?.() !== false,
626
- canActivate: canActivateCurrentProvider(instance)
627
- });
616
+ const actionState = getCurrentFooterActionState(instance);
628
617
  const saveDisabled = actionState.saveEnabled ? '' : ' disabled aria-disabled="true"';
629
618
  const useDisabled = actionState.useEnabled ? '' : ' disabled aria-disabled="true"';
630
619
  actions.innerHTML = `
@@ -638,6 +627,21 @@
638
627
  `;
639
628
  }
640
629
 
630
+ function getCurrentFooterActionState(instance) {
631
+ const provider = getSelectedProvider(instance);
632
+ const active = Boolean(provider?.id && provider.id === getCurrentProjectProviderId(instance));
633
+ return getFooterActionState({
634
+ isNew: !provider?.id,
635
+ active,
636
+ dirty: instance.dirty,
637
+ destinationChanged: active && getCurrentBaseUrl(instance) !== provider.endpointDisclosureBaseUrl,
638
+ disclosureAccepted: instance.root.querySelector('[data-provider-disclosure]')?.checked === true,
639
+ canSave: canSaveCurrentDraft(instance),
640
+ hasProject: instance.callbacks.hasCurrentProject?.() !== false,
641
+ canActivate: canActivateCurrentProvider(instance)
642
+ });
643
+ }
644
+
641
645
  function getFooterActionState(options = {}) {
642
646
  const isNew = options.isNew === true;
643
647
  const active = options.active === true;
@@ -645,9 +649,9 @@
645
649
  return {
646
650
  showSave: isNew || dirty,
647
651
  showSaveAndUse: options.hasProject !== false && (isNew || dirty) && (!active || options.destinationChanged === true),
648
- showUse: options.hasProject !== false && !isNew && !active && !dirty,
652
+ showUse: options.hasProject !== false && !isNew && (!active || options.destinationChanged === true) && !dirty,
649
653
  saveEnabled: options.canSave === true,
650
- useEnabled: options.canActivate === true
654
+ useEnabled: options.canActivate === true && (!options.destinationChanged || options.disclosureAccepted === true)
651
655
  };
652
656
  }
653
657
 
@@ -837,6 +841,11 @@
837
841
  refreshTestState(instance);
838
842
  return;
839
843
  }
844
+ if (event.target.matches('[data-provider-disclosure]')) {
845
+ renderFooter(instance);
846
+ applyBusyState(instance);
847
+ return;
848
+ }
840
849
  if (event.target.matches('[data-provider-field]')) {
841
850
  const field = event.target.dataset.providerField;
842
851
  if (event.target.dataset.providerField === 'apiKey') {
@@ -1023,8 +1032,7 @@
1023
1032
 
1024
1033
  function applyBusyState(instance) {
1025
1034
  const busy = Boolean(instance.busy && instance.busy !== 'failed');
1026
- const canSave = canSaveCurrentDraft(instance);
1027
- const canActivate = canActivateCurrentProvider(instance);
1035
+ const actionState = getCurrentFooterActionState(instance);
1028
1036
  instance.root.dataset.busy = instance.busy || '';
1029
1037
  for (const element of instance.root.querySelectorAll('input, select, textarea, button')) {
1030
1038
  if (element.matches('[data-provider-confirm-answer]')) {
@@ -1034,9 +1042,9 @@
1034
1042
  } else if (element.matches('[data-provider-action="cancel-test"]')) {
1035
1043
  element.disabled = false;
1036
1044
  } else if (element.matches('[data-provider-action="save"], [data-provider-action="save-use"]')) {
1037
- element.disabled = busy || !canSave;
1045
+ element.disabled = busy || !actionState.saveEnabled;
1038
1046
  } else if (element.matches('[data-provider-action="use"]')) {
1039
- element.disabled = busy || !canActivate;
1047
+ element.disabled = busy || !actionState.useEnabled;
1040
1048
  } else {
1041
1049
  element.disabled = busy;
1042
1050
  }
@@ -455,10 +455,10 @@
455
455
  };
456
456
  }
457
457
 
458
- async function refreshDashboardView() {
458
+ async function refreshDashboardView(options = {}) {
459
459
  var panelEl = getPanel();
460
460
  if (panelEl && panelEl.dataset.view === 'recent-projects') {
461
- await renderRecentProjectsVariant(dashboardViewOptions());
461
+ await renderRecentProjectsVariant(Object.assign(dashboardViewOptions(), options));
462
462
  }
463
463
  }
464
464
 
@@ -634,7 +634,7 @@
634
634
  : {};
635
635
  map[projectId] = record.id;
636
636
  prefs.activeSessionByProject = map;
637
- await Migration.savePrefs(prefs, accountScopeId, projectId);
637
+ await Migration.savePrefs({ activeSessionByProject: map }, accountScopeId, projectId);
638
638
  }
639
639
  } catch (_error) { /* fall through: opening the project still works */ }
640
640
  openProjectFromRow(projectId);
@@ -786,19 +786,31 @@
786
786
  input.focus();
787
787
  input.select();
788
788
  var settled = false;
789
- var finish = function (commit) {
789
+ var finish = async function (commit) {
790
790
  if (settled) {
791
791
  return;
792
792
  }
793
793
  settled = true;
794
794
  var nextRaw = input.value;
795
- input.remove();
796
- titleEl.hidden = false;
797
795
  // Unchanged input is a cancel: never rewrites the title source.
798
796
  if (!commit || nextRaw.trim() === seed.trim()) {
797
+ input.remove();
798
+ titleEl.hidden = false;
799
799
  return;
800
800
  }
801
- commitDashboardSessionRename(sessionsEl, projectId, record, nextRaw).catch(function () { /* swallow */ });
801
+ input.readOnly = true;
802
+ try {
803
+ await commitDashboardSessionRename(sessionsEl, projectId, record, nextRaw);
804
+ } catch (_error) {
805
+ settled = false;
806
+ input.readOnly = false;
807
+ showPluginToast(tr('recentProjects_renameFailed'), { status: 'failed', sticky: true });
808
+ input.focus();
809
+ return;
810
+ }
811
+ input.remove();
812
+ titleEl.hidden = false;
813
+ await refreshDashboardView().catch(function () { /* keep last render */ });
802
814
  };
803
815
  input.addEventListener('keydown', function (event) {
804
816
  if (event.key === 'Enter') {
@@ -833,15 +845,12 @@
833
845
  return SessionState.renameSession(state, record.id, rawTitle, { placeholderTitle: tr('newSessionFallback') });
834
846
  });
835
847
  } catch (_error) { /* blob may be absent; record update still proceeds */ }
836
- try {
837
- if (StorageDb) {
838
- await StorageDb.putRecord('sessions', StorageDb.buildSessionRecord(Object.assign({}, record, {
839
- title: next.title,
840
- titleSource: next.titleSource
841
- })));
842
- }
843
- } catch (_error) { /* swallow */ }
844
- await refreshDashboardView();
848
+ if (StorageDb) {
849
+ await StorageDb.putRecord('sessions', StorageDb.buildSessionRecord(Object.assign({}, record, {
850
+ title: next.title,
851
+ titleSource: next.titleSource
852
+ }), { preserveRunActionPayload: true }));
853
+ }
845
854
  }
846
855
 
847
856
  function ensureRecentProjectsRoot() {
@@ -1071,6 +1080,7 @@
1071
1080
  openProjectFromRow,
1072
1081
  renderRecentProjectRow,
1073
1082
  renderRecentProjectsVariant,
1083
+ refreshDashboardView,
1074
1084
  mountDashboardShell,
1075
1085
  resetProjectNameCache,
1076
1086
  renderPerProjectVariant
@@ -42,10 +42,15 @@
42
42
  reason: status === 'completed' ? 'completed' : 'continue_queue'
43
43
  })
44
44
  );
45
- } else if (!shouldContinue) {
46
- const paused = originalQueue.length
47
- ? Queue.pauseAll(originalQueue, status || 'run_did_not_complete')
48
- : originalQueue;
45
+ }
46
+ const canStart = shouldContinue ? options.canStart?.(sessionId) : true;
47
+ const latestQueue = options.getQueue?.(sessionId) || originalQueue;
48
+ const inactive = shouldContinue && canStart !== 'session_transition'
49
+ && (canStart === false || options.isRunning?.())
50
+ && latestQueue.some(item => item?.status === 'queued');
51
+ if (!shouldContinue || inactive) {
52
+ const reason = inactive ? 'session_inactive' : status || 'run_did_not_complete';
53
+ const paused = latestQueue.length ? Queue.pauseAll(latestQueue, reason) : latestQueue;
49
54
  options.setQueue?.(sessionId, paused);
50
55
  try {
51
56
  await options.persist?.({
@@ -55,19 +60,23 @@
55
60
  const latestQueue = options.getQueue?.(sessionId) || paused;
56
61
  options.setQueue?.(
57
62
  sessionId,
58
- Queue.pauseAll(latestQueue, status || 'run_did_not_complete')
63
+ Queue.pauseAll(latestQueue, reason)
59
64
  );
60
65
  throw error;
61
66
  }
62
67
  }
63
68
  options.onChange?.(sessionId);
64
- if (shouldContinue) {
69
+ if (shouldContinue && !inactive) {
65
70
  scheduleOne(sessionId);
66
71
  }
67
72
  }
68
73
 
69
74
  function scheduleOne(sessionId) {
70
- if (scheduling || options.isRunning?.() || options.canStart?.(sessionId) === false) {
75
+ if (scheduling || options.isRunning?.()) {
76
+ return false;
77
+ }
78
+ const canStart = options.canStart?.(sessionId);
79
+ if (canStart === false || canStart === 'session_transition') {
71
80
  return false;
72
81
  }
73
82
  const initialQueue = options.getQueue?.(sessionId) || [];
@@ -195,7 +204,7 @@
195
204
  setQueue: (sessionId, pendingInputs) => options.setQueue?.(sessionId, pendingInputs),
196
205
  persist: persistenceOptions => options.save?.(persistenceOptions),
197
206
  isRunning: () => Boolean(options.getCurrentRun?.()),
198
- canStart: sessionId => options.canStart?.(sessionId) !== false,
207
+ canStart: sessionId => options.canStart?.(sessionId),
199
208
  start: (item, sessionId) => startQueuedInput(item, sessionId),
200
209
  onChange: () => render(),
201
210
  onDequeued: (item, context) => options.removeGuidance?.(item.id, {
@@ -445,7 +454,8 @@
445
454
 
446
455
  async function startQueuedInput(item, sessionId) {
447
456
  const assertCanStart = () => {
448
- if (options.canStart?.(sessionId) === false || options.getCurrentRun?.()) {
457
+ const canStart = options.canStart?.(sessionId);
458
+ if (canStart === false || canStart === 'session_transition' || options.getCurrentRun?.()) {
449
459
  const error = new Error('Queued input cannot start in the current session.');
450
460
  error.code = 'queue_session_not_active';
451
461
  throw error;
@@ -74,17 +74,18 @@
74
74
  const existing = root.querySelector('[data-run-copy]');
75
75
  const button = existing.cloneNode(true);
76
76
  existing.replaceWith(button);
77
- const finalAnswer = root.querySelector('.run-completion-report .run-final-answer');
78
- const copyText = String(finalAnswer?.innerText || finalAnswer?.textContent || '').trim();
77
+ const available = Boolean(readConclusionText(root).trim());
79
78
  button.hidden = false;
80
- button.disabled = !copyText;
79
+ button.disabled = !available;
81
80
  button.title = tr('copyConclusionTitle');
82
81
  button.setAttribute('aria-label', button.title);
83
82
  button.innerHTML = '<svg viewBox="0 0 20 20" aria-hidden="true"><g transform="translate(.75 .75)"><rect x="6.5" y="6.5" width="9" height="9" rx="2"/><path d="M13.5 6.5V5a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v6.5a2 2 0 0 0 2 2h1.5"/></g></svg>';
84
- if (!copyText) return;
83
+ if (!available) return;
85
84
  button.addEventListener('click', async event => {
86
85
  event.stopPropagation();
87
86
  try {
87
+ const copyText = readConclusionText(root);
88
+ if (!copyText.trim()) return;
88
89
  await writeClipboardText(copyText);
89
90
  button.title = tr('copyConclusionDone');
90
91
  } catch (_error) {
@@ -94,6 +95,52 @@
94
95
  });
95
96
  }
96
97
 
98
+ function readConclusionText(root) {
99
+ const answer = root.querySelector('.run-completion-report .run-final-answer');
100
+ if (!answer) return '';
101
+ return extractConclusionText(answer).replace(/\u200b/g, '').replace(/[ \t]+$/gm, '').trim();
102
+ }
103
+
104
+ function extractConclusionText(node) {
105
+ if (node.nodeType === 3) return String(node.textContent || '').replace(/\s+/g, ' ');
106
+ const tag = node.tagName?.toLowerCase();
107
+ if (tag === 'br') return '\n';
108
+ if (tag === 'pre' || tag === 'code') return node.textContent || '';
109
+ if (node.classList?.contains('run-math')) {
110
+ const source = node.dataset.mathRendered === 'false'
111
+ ? node.textContent || ''
112
+ : node.querySelector('annotation[encoding="application/x-tex"]')?.textContent || '';
113
+ const delimiter = node.dataset.mathDisplay === 'block' ? '$$' : '$';
114
+ return source ? `${delimiter}${source}${delimiter}` : '';
115
+ }
116
+ const children = Array.from(node.childNodes || []);
117
+ if (tag === 'ul' || tag === 'ol') {
118
+ const start = Number(node.getAttribute('start') || 1);
119
+ return children.filter(child => child.tagName?.toLowerCase() === 'li')
120
+ .map((child, index) => `${tag === 'ol' ? `${start + index}. ` : '- '}${extractConclusionText(child)}`)
121
+ .join('\n');
122
+ }
123
+ if (tag === 'table') {
124
+ return Array.from(node.querySelectorAll('tr'), row => Array.from(row.children,
125
+ cell => extractConclusionText(cell)).join('\t')).join('\n');
126
+ }
127
+ let text = '';
128
+ let previousBlock = false;
129
+ for (const child of children) {
130
+ const value = extractConclusionText(child);
131
+ if (!value) continue;
132
+ const block = /^(p|h[1-6]|blockquote|ul|ol|pre|table|div)$/.test(child.tagName?.toLowerCase())
133
+ || child.dataset?.mathDisplay === 'block';
134
+ if (text && (previousBlock || block)) {
135
+ const newlines = (text.match(/\n*$/)?.[0].length || 0) + (value.match(/^\n*/)?.[0].length || 0);
136
+ text += '\n'.repeat(Math.max(0, 2 - newlines));
137
+ }
138
+ text += value;
139
+ previousBlock = block;
140
+ }
141
+ return text;
142
+ }
143
+
97
144
  async function writeClipboardText(text) {
98
145
  if (navigatorRef?.clipboard?.writeText) {
99
146
  await navigatorRef.clipboard.writeText(text);