@oar-sh/oar 0.9.24 → 0.9.26

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 (92) hide show
  1. package/.github/extensions/web-relay/skills/reasoning-stream.mjs +3 -1
  2. package/CHANGELOG.md +284 -0
  3. package/README.md +12 -11
  4. package/package.json +5 -1
  5. package/server/claude-worker/claude-remote-relay-tool.mjs +15 -0
  6. package/server/claude-worker/claude-sdk-adapter.mjs +8 -4
  7. package/server/claude-worker/claude-session-process.mjs +4 -0
  8. package/server/claude-worker/claude-turn-publisher.mjs +25 -11
  9. package/server/claude-worker/sdk-message-normalizer.mjs +29 -1
  10. package/server/copilot-worker/copilot-prompt-context.mjs +2 -1
  11. package/server/copilot-worker/copilot-sdk-session-process.mjs +11 -9
  12. package/server/cursor-worker/cursor-turn-runner.mjs +3 -1
  13. package/server/cursor-worker/sdk-message-normalizer.mjs +13 -0
  14. package/server/db-schema.mjs +19 -0
  15. package/server/grok-worker/grok-turn-runner.mjs +3 -1
  16. package/server/grok-worker/sdk-message-normalizer.mjs +3 -0
  17. package/server/migrations/0039-media-index.mjs +167 -0
  18. package/server/migrations/0040-image-session-defaults.mjs +93 -0
  19. package/server/public/app/api-client.js +58 -0
  20. package/server/public/app/ask-user-view.js +2 -1
  21. package/server/public/app/attachments-view.js +66 -9
  22. package/server/public/app/bootstrap.js +85 -25
  23. package/server/public/app/chat-actions-menu-state.mjs +1 -0
  24. package/server/public/app/context-media-tab.js +143 -0
  25. package/server/public/app/context-usage-view.mjs +2 -2
  26. package/server/public/app/conversation-view.js +90 -5
  27. package/server/public/app/image-composer-ui.js +518 -0
  28. package/server/public/app/image-turn-placeholder.mjs +233 -0
  29. package/server/public/app/journal-view.js +12 -2
  30. package/server/public/app/live-activity-cap.mjs +3 -3
  31. package/server/public/app/markdown-math.mjs +471 -0
  32. package/server/public/app/media-gallery-model.mjs +202 -0
  33. package/server/public/app/media-gallery-ui.js +524 -0
  34. package/server/public/app/media-view.js +79 -0
  35. package/server/public/app/openai-image-composer.mjs +492 -0
  36. package/server/public/app/openai-image-cost-ui.js +426 -0
  37. package/server/public/app/openai-image-spend.mjs +443 -0
  38. package/server/public/app/plan-usage-view.mjs +38 -6
  39. package/server/public/app/pwa-install.js +25 -4
  40. package/server/public/app/relay-error-ctas.mjs +5 -0
  41. package/server/public/app/relay-prompt-sanitizer.mjs +3 -0
  42. package/server/public/app/relay-restart-ui.js +2 -2
  43. package/server/public/app/remote-relays-settings.js +33 -6
  44. package/server/public/app/router.js +49 -237
  45. package/server/public/app/session-history-ui.js +2 -0
  46. package/server/public/app/settings-modal.js +4 -0
  47. package/server/public/app/settings-tabs.js +10 -1
  48. package/server/public/app/socket-handlers.js +26 -0
  49. package/server/public/app/store.js +2 -0
  50. package/server/public/app/stream-view-preference.mjs +1 -1
  51. package/server/public/app/tab-strip-fade.mjs +1 -1
  52. package/server/public/app/thoughts-preference.mjs +1 -1
  53. package/server/public/app/tmux-inspector-view.js +29 -16
  54. package/server/public/app/viewer-gallery.mjs +23 -1
  55. package/server/public/index.html +517 -123
  56. package/server/relay-tools.md +1 -0
  57. package/server/repositories/image-conversation-repository.mjs +69 -0
  58. package/server/repositories/media-repository.mjs +305 -0
  59. package/server/routes/media-routes.mjs +361 -0
  60. package/server/routes/messages-routes.mjs +1410 -360
  61. package/server/routes/relay-board-routes.mjs +46 -1
  62. package/server/routes/remote-relay-routes.mjs +1 -1
  63. package/server/routes/sessions-routes.mjs +361 -14
  64. package/server/server-runtime.mjs +21 -0
  65. package/server/services/database-file.mjs +6 -4
  66. package/server/services/generated-image-cleanup-service.mjs +2 -1
  67. package/server/services/generated-image-files.mjs +110 -0
  68. package/server/services/image-input-paths.mjs +389 -0
  69. package/server/services/image-operation-service.mjs +6 -2
  70. package/server/services/media-extract.mjs +653 -0
  71. package/server/services/media-index-service.mjs +221 -0
  72. package/server/services/openai-image-preview-turns.mjs +330 -0
  73. package/server/services/openai-image-spend-service.mjs +516 -0
  74. package/server/services/openai-image-stream.mjs +170 -0
  75. package/server/services/plan-usage-openai-image.mjs +148 -0
  76. package/server/services/relay-prompt-sanitizer.mjs +7 -0
  77. package/server/services/relay-restart-queue.mjs +1 -1
  78. package/server/services/remote-relay-caller-context.mjs +6 -0
  79. package/server/services/remote-relay-dispatcher.mjs +409 -23
  80. package/server/services/remote-relay-registry-service.mjs +10 -2
  81. package/server/services/session-worker-stop-service.mjs +50 -18
  82. package/server/services/token-usage-service.mjs +15 -0
  83. package/shared/image-header.mjs +90 -0
  84. package/shared/math-instructions.mjs +30 -0
  85. package/shared/openai-image-options.mjs +299 -0
  86. package/shared/openai-image-presets.mjs +175 -0
  87. package/shared/openai-image-previews.mjs +40 -0
  88. package/shared/openai-image-prices.mjs +291 -0
  89. package/shared/openai-image-spend.mjs +90 -0
  90. package/shared/remote-relay-contract.mjs +126 -4
  91. package/shared/worker-runtime/direct-openai-image.mjs +96 -50
  92. package/server/public/app/math-dollars.mjs +0 -89
@@ -23,6 +23,8 @@ import {
23
23
  upsertRemoteRelay,
24
24
  } from './remote-relays-store.mjs';
25
25
  import { REMOTE_RELAY_PERMISSIONS, normalizeRemoteRelayPermission } from './remote-relay-shared.mjs';
26
+ import { currentPwaAppName, isPwaAppNameSaveInFlight, syncPwaAppNameInput } from './pwa-install.js';
27
+ import { SETTINGS_TAB_SHOWN_EVENT } from './settings-tabs.js';
26
28
 
27
29
  export const REMOTE_RELAY_PERMISSION_LABELS = Object.freeze({
28
30
  read: 'Read only',
@@ -54,6 +56,9 @@ let listLoadFailed = false;
54
56
  const rowBusy = new Map();
55
57
  let listRenderDeferred = false;
56
58
  let listFocusBound = false;
59
+ // The relay's name as the last snapshot had it: a change made elsewhere is
60
+ // read into the Relay name field.
61
+ let lastSelfName = null;
57
62
 
58
63
  function el(id) {
59
64
  return document.getElementById(id);
@@ -164,12 +169,10 @@ function setStatusLine(id, state, text) {
164
169
  }
165
170
 
166
171
  function renderSelfSection() {
167
- const snapshot = getRemoteRelaysSnapshot();
168
- const nameEl = el('remote-relays-self-name');
169
- if (nameEl) {
170
- const name = String(snapshot?.self?.name || '').trim();
171
- nameEl.textContent = name || '—';
172
- }
172
+ // Empty, the field shows the name the relay goes by then (its host name).
173
+ const nameInput = el('pwa-app-name-input');
174
+ const defaultName = String(getRemoteRelaysSnapshot()?.self?.defaultName || '').trim();
175
+ if (nameInput && defaultName) nameInput.placeholder = defaultName;
173
176
  const input = el('remote-relays-public-url-input');
174
177
  if (input) {
175
178
  input.placeholder = browserSelfUrl() || 'https://…';
@@ -590,9 +593,27 @@ export function resetRemoteRelaysSettingsForTests() {
590
593
  addInFlight = false;
591
594
  listLoadFailed = false;
592
595
  listRenderDeferred = false;
596
+ lastSelfName = null;
593
597
  rowBusy.clear();
594
598
  }
595
599
 
600
+ // A rename on another device (the relay announces it with the list) or the
601
+ // Relays tab coming into view reads the name again; the field keeps what is
602
+ // being typed in it (pwa-install.js).
603
+ function refreshRelayNameOnChange(snapshot) {
604
+ const name = typeof snapshot?.self?.name === 'string' ? snapshot.self.name : null;
605
+ if (name === null) return;
606
+ const changed = lastSelfName !== null && name !== lastSelfName;
607
+ lastSelfName = name;
608
+ if (!changed) return;
609
+ // This page's own rename: its answer paints the field.
610
+ if (isPwaAppNameSaveInFlight()) return;
611
+ // A name this page already has (an empty one goes by the default).
612
+ const known = currentPwaAppName() || String(snapshot.self.defaultName || '');
613
+ if (name === known) return;
614
+ syncPwaAppNameInput();
615
+ }
616
+
596
617
  // The relay's own settings ride along in the list payload's `self` (GET and
597
618
  // remote_relays_updated alike), so a change made on another device shows here
598
619
  // too — except while this page is saving the same field.
@@ -608,6 +629,7 @@ function applySelfSettingsFromSnapshot(snapshot) {
608
629
  // Live: a remote_relays_updated event (or any other store change) repaints.
609
630
  subscribeRemoteRelays((snapshot) => {
610
631
  applySelfSettingsFromSnapshot(snapshot);
632
+ refreshRelayNameOnChange(snapshot);
611
633
  renderRemoteRelaysSection();
612
634
  });
613
635
 
@@ -615,4 +637,9 @@ if (typeof window !== 'undefined' && typeof window.addEventListener === 'functio
615
637
  window.addEventListener(RELAY_NAME_CHANGED_EVENT, () => {
616
638
  void refreshRemoteRelays();
617
639
  });
640
+ window.addEventListener(SETTINGS_TAB_SHOWN_EVENT, (event) => {
641
+ if (event?.detail?.tab !== 'relays') return;
642
+ syncPwaAppNameInput();
643
+ void refreshRemoteRelays();
644
+ });
618
645
  }
@@ -7,7 +7,7 @@ import {
7
7
  WORKSPACE_FILE_EXTENSIONS,
8
8
  serverPlatform,
9
9
  } from './store.js';
10
- import { scanInlineDollars, splitAtLiteralDollars } from './math-dollars.mjs';
10
+ import { mathAppliesTo, setMathEnabled, texForKatex } from './markdown-math.mjs';
11
11
 
12
12
  export function workspaceMentionRegex() {
13
13
  return /(?:[A-Za-z]:[\\/])?(?:\.{1,2}[\\/])?(?:[A-Za-z0-9._-]+[\\/])*[A-Za-z0-9._-]+\.[A-Za-z0-9]{1,16}/g;
@@ -261,6 +261,9 @@ function isAllowedPreviewAttribute(tagName, name, value) {
261
261
  return (tagName === 'video' || tagName === 'audio') && isSafePreviewUrl(value, false);
262
262
  }
263
263
  if (name === 'alt') return tagName === 'img';
264
+ // The formula placeholders of markdown-math.mjs, filled in by renderMathInHtml.
265
+ if (name === 'data-math') return tagName === 'span' && (value === 'inline' || value === 'display');
266
+ if (name === 'data-tex') return tagName === 'span';
264
267
  if (name === 'controls' || name === 'muted' || name === 'loop') {
265
268
  return (tagName === 'video' || tagName === 'audio') && value === '';
266
269
  }
@@ -372,262 +375,71 @@ export function sanitizePreviewHtml(html) {
372
375
  return template.innerHTML;
373
376
  }
374
377
 
375
- const COMPATIBILITY_DISPLAY_MATH_COMMAND = /\\(?:frac|sqrt|sum|prod|int|iint|iiint|lim|log|ln|sin|cos|tan|theta|alpha|beta|gamma|delta|lambda|mu|pi|sigma|phi|omega|vec|hat|bar|overline|underline|left|right|begin|end|text|mathrm|mathbf|mathbb|mathcal|ce)\b/i;
376
- const INLINE_MATH_COMMAND = /\\(?:frac|sqrt|sum|prod|int|iint|iiint|lim|log|ln|sin|cos|tan|cot|sec|csc|arcsin|arccos|theta|alpha|beta|gamma|delta|lambda|mu|pi|rho|sigma|phi|omega|Omega|Gamma|Delta|Lambda|Pi|Sigma|Phi|Psi|Omega|vec|hat|bar|overline|underline|left|right|text|mathrm|mathbf|mathbb|mathcal|operatorname|circ|degree|times|cdot|pm|mp|leq|geq|neq|approx|equiv|infty|partial|nabla|forall|exists|rightarrow|longmapsto|to|mapsto|ce)\b/;
377
- const STRICT_INLINE_SCRIPT = /(?:\^[{]|\^[-+]?\d|_[{]|_[-+]?\d)/;
378
- const URL_LIKE_TEXT = /\b(?:https?|ftp):\/\/|www\./i;
379
- const MARKDOWN_ESCAPABLE_TEX_CHARACTERS = new Set(Array.from('!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~'));
378
+ // Rendered formulas by mode and TeX; null for one KaTeX refused. A streaming
379
+ // reply is rendered again with every delta, and its earlier formulas come
380
+ // from here.
381
+ const MATH_HTML_CACHE_MAX = 500;
382
+ const mathHtmlCache = new Map();
380
383
 
381
- function isStrictInlineMathCandidate(value) {
382
- const text = String(value || '').trim();
383
- if (!text || text.length > 240 || URL_LIKE_TEXT.test(text)) return false;
384
- return INLINE_MATH_COMMAND.test(text) || STRICT_INLINE_SCRIPT.test(text);
385
- }
386
-
387
- function findClosingParenthesis(source, openingIndex) {
388
- let depth = 0;
389
- for (let index = openingIndex; index < source.length; index += 1) {
390
- if (source[index] === '\\') {
391
- index += 1;
392
- continue;
393
- }
394
- if (source[index] === '(') depth += 1;
395
- if (source[index] === ')') {
396
- depth -= 1;
397
- if (depth === 0) return index;
398
- }
399
- }
400
- return -1;
401
- }
402
-
403
- function normalizeInlineMathDelimiters(line) {
404
- let output = '';
405
- let index = 0;
406
- let inCode = false;
407
- // Only real dollar maths is skipped over; a price's `$` opens nothing.
408
- const dollarMathEnds = new Map(scanInlineDollars(line).spans);
409
-
410
- while (index < line.length) {
411
- const char = line[index];
412
- if (char === '`') {
413
- inCode = !inCode;
414
- output += char;
415
- index += 1;
416
- continue;
417
- }
418
- if (inCode) {
419
- output += char;
420
- index += 1;
421
- continue;
422
- }
423
- if (char === '$' && dollarMathEnds.has(index)) {
424
- const closeIndex = dollarMathEnds.get(index);
425
- output += line.slice(index, closeIndex + 1);
426
- index = closeIndex + 1;
427
- continue;
428
- }
429
- if (char === '\\' && line[index + 1] === '(') {
430
- const closeIndex = line.indexOf('\\)', index + 2);
431
- if (closeIndex >= 0) {
432
- output += `$${line.slice(index + 2, closeIndex).trim()}$`;
433
- index = closeIndex + 2;
434
- continue;
435
- }
436
- }
437
- if (char !== '(' || line[index - 1] === '\\') {
438
- output += char;
439
- index += 1;
440
- continue;
441
- }
442
- const closeIndex = findClosingParenthesis(line, index);
443
- if (closeIndex < 0) {
444
- output += char;
445
- index += 1;
446
- continue;
447
- }
448
- const candidate = line.slice(index + 1, closeIndex);
449
- if (!isStrictInlineMathCandidate(candidate)) {
450
- output += char;
451
- index += 1;
452
- continue;
453
- }
454
- output += `$${candidate.trim()}$`;
455
- index = closeIndex + 1;
456
- }
457
-
458
- return output;
459
- }
460
-
461
- function protectMarkdownEscapesInMath(source) {
462
- const lines = String(source ?? '').split('\n');
463
- let inFence = false;
464
-
465
- return lines.map((line) => {
466
- if (/^\s*(?:`{3,}|~{3,})/.test(line)) {
467
- inFence = !inFence;
468
- return line;
469
- }
470
- if (inFence) return line;
471
-
472
- let output = '';
473
- let index = 0;
474
- let inCode = false;
475
- const dollarMathEnds = new Map(scanInlineDollars(line).spans);
476
- while (index < line.length) {
477
- if (line[index] === '`') {
478
- inCode = !inCode;
479
- output += line[index];
480
- index += 1;
481
- continue;
482
- }
483
- if (inCode || line[index] !== '$' || line[index - 1] === '\\') {
484
- output += line[index];
485
- index += 1;
486
- continue;
487
- }
488
- const delimiter = line[index + 1] === '$' ? '$$' : '$';
489
- // A single `$` that is a plain dollar sign (a price) opens no formula.
490
- const closeIndex = delimiter === '$'
491
- ? (dollarMathEnds.has(index) ? dollarMathEnds.get(index) : -1)
492
- : line.indexOf(delimiter, index + delimiter.length);
493
- if (closeIndex < 0) {
494
- output += line[index];
495
- index += 1;
496
- continue;
497
- }
498
- const formula = line.slice(index + delimiter.length, closeIndex);
499
- const protectedFormula = formula.replace(/\\(.)/g, (match, character) => (
500
- MARKDOWN_ESCAPABLE_TEX_CHARACTERS.has(character) ? `\\\\${character}` : match
501
- ));
502
- output += `${delimiter}${protectedFormula}${delimiter}`;
503
- index = closeIndex + delimiter.length;
504
- }
505
- return output;
506
- }).join('\n');
507
- }
508
-
509
- export function normalizeMathDelimiters(source) {
510
- const lines = String(source ?? '').split('\n');
511
- const normalized = [];
512
- let inFence = false;
513
-
514
- for (let index = 0; index < lines.length; index += 1) {
515
- let line = lines[index];
516
- if (/^\s*(?:`{3,}|~{3,})/.test(line)) {
517
- inFence = !inFence;
518
- normalized.push(line);
519
- continue;
520
- }
521
- if (inFence || /^(?: {4}|\t)/.test(line)) {
522
- normalized.push(line);
523
- continue;
524
- }
525
-
526
- line = normalizeInlineMathDelimiters(line)
527
- .replace(/\\\[([^\n]+?)\\\]/g, (_match, formula) => `$$${formula.trim()}$$`);
528
- const delimiter = line.trim();
529
- const closingDelimiter = delimiter === '$$'
530
- ? '$$'
531
- : delimiter === '\\['
532
- ? '\\]'
533
- : delimiter === '['
534
- ? ']'
535
- : '';
536
- if (!closingDelimiter) {
537
- normalized.push(line);
538
- continue;
539
- }
540
-
541
- let closingIndex = index + 1;
542
- while (closingIndex < lines.length && lines[closingIndex].trim() !== closingDelimiter) closingIndex += 1;
543
- if (closingIndex >= lines.length) {
544
- normalized.push(line);
545
- continue;
546
- }
547
-
548
- const formula = lines.slice(index + 1, closingIndex).join('\n');
549
- if (delimiter === '[' && !COMPATIBILITY_DISPLAY_MATH_COMMAND.test(formula)) {
550
- normalized.push(line);
551
- continue;
552
- }
553
- normalized.push(`$$${formula.trim().replace(/[ \t]*\n[ \t]*/g, ' ')}$$`);
554
- index = closingIndex;
384
+ function katexHtml(tex, display) {
385
+ const key = `${display ? 'd' : 'i'}${tex}`;
386
+ if (mathHtmlCache.has(key)) return mathHtmlCache.get(key);
387
+ let html = null;
388
+ try {
389
+ html = globalThis.katex.renderToString(texForKatex(tex), {
390
+ displayMode: display,
391
+ throwOnError: true,
392
+ // Set, not left to KaTeX's default ('warn'): input LaTeX would not
393
+ // take, and a character KaTeX has no metrics for (an emoji), is set
394
+ // as well as it can be, without a console warning per render.
395
+ strict: 'ignore',
396
+ trust: false,
397
+ // A command KaTeX does not trust (\href, \url, \includegraphics) is
398
+ // set as its own name, in this colour: the text's, not red.
399
+ errorColor: 'inherit',
400
+ });
401
+ } catch {
402
+ // Nothing KaTeX can set (an unknown command, a syntax error): the source
403
+ // stays as it was written. Prose that got past the delimiter rules ends
404
+ // here too, so it never shows as a broken formula.
555
405
  }
556
-
557
- return protectMarkdownEscapesInMath(normalized.join('\n'));
406
+ if (mathHtmlCache.size >= MATH_HTML_CACHE_MAX) mathHtmlCache.clear();
407
+ mathHtmlCache.set(key, html);
408
+ return html;
558
409
  }
559
410
 
560
- const MATH_IGNORED_TAGS = ['pre', 'code', 'script', 'style', 'textarea', 'option', 'a'];
561
- const LITERAL_DOLLAR_CLASS = 'literal-dollar';
562
-
563
411
  /**
564
- * KaTeX's auto-render pairs any two `$` in a block, so "$5 … $9" would become
565
- * one formula. Each plain dollar sign (see math-dollars.mjs) is moved into a
566
- * span of its own that auto-render skips; with no partner in its text node,
567
- * it stays a dollar sign.
412
+ * Fills the formula placeholders of markdown-math.mjs with KaTeX's rendering.
413
+ * It runs after the sanitiser: KaTeX generates its own layout styles, which
414
+ * are necessary for fractions, scripts and matrices and which that pass would
415
+ * strip. Without KaTeX the placeholders stay, and read as the source.
568
416
  */
569
- function isolateLiteralDollars(container) {
570
- const ignored = MATH_IGNORED_TAGS.join(',');
571
- const walker = document.createTreeWalker(container, 4 /* NodeFilter.SHOW_TEXT */);
572
- const textNodes = [];
573
- for (let node = walker.nextNode(); node; node = walker.nextNode()) {
574
- if (node.nodeValue.includes('$') && !node.parentElement?.closest(ignored)) textNodes.push(node);
575
- }
576
- for (const node of textNodes) {
577
- const parts = splitAtLiteralDollars(node.nodeValue);
578
- if (!parts.some((part) => part.literal)) continue;
579
- const fragment = document.createDocumentFragment();
580
- for (const part of parts) {
581
- if (!part.literal) {
582
- fragment.appendChild(document.createTextNode(part.text));
583
- continue;
584
- }
585
- const span = document.createElement('span');
586
- span.className = LITERAL_DOLLAR_CLASS;
587
- span.textContent = part.text;
588
- fragment.appendChild(span);
589
- }
590
- node.parentNode.replaceChild(fragment, node);
591
- }
592
- }
593
-
594
417
  function renderMathInHtml(html) {
595
- const renderMathInElement = globalThis.renderMathInElement;
596
- if (typeof renderMathInElement !== 'function' || !globalThis.document?.createElement) return html;
418
+ if (!html.includes('data-math=')) return html;
419
+ if (typeof globalThis.katex?.renderToString !== 'function' || !globalThis.document?.createElement) return html;
597
420
 
598
421
  const container = document.createElement('div');
599
422
  container.innerHTML = html;
600
- try {
601
- isolateLiteralDollars(container);
602
- renderMathInElement(container, {
603
- delimiters: [
604
- { left: '$$', right: '$$', display: true },
605
- { left: '\\[', right: '\\]', display: true },
606
- { left: '\\(', right: '\\)', display: false },
607
- { left: '$', right: '$', display: false },
608
- ],
609
- ignoredTags: MATH_IGNORED_TAGS,
610
- ignoredClasses: [LITERAL_DOLLAR_CLASS],
611
- throwOnError: false,
612
- strict: 'ignore',
613
- trust: false,
614
- });
615
- } catch (error) {
616
- console.warn('[markdown] could not render mathematical notation', error);
617
- return html;
423
+ for (const placeholder of container.querySelectorAll('span[data-math]')) {
424
+ const display = placeholder.getAttribute('data-math') === 'display';
425
+ const rendered = katexHtml(placeholder.getAttribute('data-tex') || '', display);
426
+ placeholder.removeAttribute('data-tex');
427
+ if (rendered === null) placeholder.classList.add('math-source');
428
+ else placeholder.innerHTML = rendered;
618
429
  }
619
- // The original Markdown was sanitized before this pass. KaTeX generates its own
620
- // layout styles, which are necessary for fractions, scripts, and matrices.
621
430
  return container.innerHTML;
622
431
  }
623
432
 
624
433
  export function renderMarkdownPreview(source, allowEmbeddedHtml) {
625
- const sourceText = normalizeMathDelimiters(source);
434
+ const sourceText = String(source ?? '');
626
435
  const fallbackEscaped = `<p>${escHtml(sourceText).replace(/\n/g, '<br>')}</p>`;
627
436
  const markdown = globalThis.marked;
628
437
  if (!markdown || typeof markdown.parse !== 'function' || typeof markdown.Renderer !== 'function') {
629
438
  return fallbackEscaped;
630
439
  }
440
+ // A message without a maths delimiter, or a very long one, is parsed
441
+ // without the maths rules.
442
+ setMathEnabled(mathAppliesTo(sourceText));
631
443
  if (allowEmbeddedHtml) {
632
444
  const parsed = markdown.parse(sourceText);
633
445
  if (typeof parsed !== 'string') return fallbackEscaped;
@@ -77,6 +77,8 @@ function overlayDescriptors(closers) {
77
77
  { name: 'inbox', find: () => document.body, isOpen: (el) => !!el?.classList?.contains('inbox-view-active'), close: backToSession(closers.closeInboxView) },
78
78
  // Status the same way.
79
79
  { name: 'status', find: () => document.body, isOpen: (el) => !!el?.classList?.contains('status-view-active'), close: backToSession(closers.closeStatusView) },
80
+ // And the global Media view.
81
+ { name: 'media', find: () => document.body, isOpen: (el) => !!el?.classList?.contains('media-view-active'), close: backToSession(closers.closeMediaView) },
80
82
  { name: 'composer-settings', find: byId('composer-settings'), isOpen: isShown, close: closers.closeComposerSettings },
81
83
  { name: 'chat-actions-menu', find: byId('chat-actions-menu'), isOpen: isShown, close: closers.closeChatActionsMenu },
82
84
  { name: 'summary-modal', find: byId('summary-modal'), isOpen: isVisible, close: closers.closeSummaryModal },
@@ -65,6 +65,7 @@ import { refreshClaudeAuthSection } from './claude-auth-ui.js';
65
65
  import { refreshGrokAuthSection } from './grok-auth-ui.js';
66
66
  import { syncDeepSeekSettingsInputs } from './deepseek-settings-ui.js';
67
67
  import { syncClaudeApiSettingsInputs } from './claude-api-settings-ui.js';
68
+ import { applyOpenAIImageSpendSettings, syncOpenAIImageSpendSettings } from './openai-image-cost-ui.js';
68
69
 
69
70
  export {
70
71
  applyDeepSeekSettingsState,
@@ -642,6 +643,8 @@ function setOpenAISettingsControlsDisabled(disabled) {
642
643
 
643
644
  export function applyOpenAISettingsState(settings = {}, { resetInputs = false } = {}) {
644
645
  ensureOpenAISettingsInputTracking();
646
+ // The daily image cap, when the answer names it (openai-image-cost-ui.js).
647
+ if (typeof document !== 'undefined') applyOpenAIImageSpendSettings(settings || {});
645
648
  openAISettingsState = {
646
649
  configured: settings?.configured === true,
647
650
  enabled: settings?.configured === true && settings?.enabled === true,
@@ -2120,6 +2123,7 @@ export function openSettingsModal(tab, providerTab) {
2120
2123
  openAISettingsInputsDirty = false;
2121
2124
  ensureOpenAISettingsInputTracking();
2122
2125
  void syncOpenAISettingsInputs();
2126
+ void syncOpenAIImageSpendSettings();
2123
2127
  claudeSettingsInputsDirty = false;
2124
2128
  ensureClaudeSettingsInputTracking();
2125
2129
  void syncClaudeSettingsInputs();
@@ -1,9 +1,13 @@
1
1
  import { revealSelectedTabs, watchTabStripFades } from './tab-strip-fade.mjs';
2
2
 
3
3
  const SETTINGS_TAB_STORAGE_KEY = 'copilot_settings_tab';
4
+ export const SETTINGS_TAB_SHOWN_EVENT = 'oar:settings-tab-shown';
4
5
  const SETTINGS_PROVIDER_TAB_STORAGE_KEY = 'copilot_settings_provider_tab';
5
6
 
6
- const SETTINGS_TABS = ['general', 'providers', 'relays', 'previews', 'shares', 'notifications', 'features'];
7
+ // General leads: `SETTINGS_TABS[0]` is the tab a first-time visitor lands on.
8
+ // A remembered tab or a deep link to General still lands there after its
9
+ // Appearance block got a tab of its own.
10
+ const SETTINGS_TABS = ['general', 'appearance', 'providers', 'relays', 'previews', 'shares', 'notifications', 'features'];
7
11
  // Copilot leads: it is the default provider, and `SETTINGS_PROVIDER_TABS[0]`
8
12
  // is also the sub-tab a first-time visitor lands on.
9
13
  const SETTINGS_PROVIDER_TABS = ['copilot', 'openai', 'claude', 'claude-api', 'claude-cloud', 'grok', 'cursor', 'deepseek'];
@@ -122,8 +126,13 @@ export function selectSettingsTab(tab, providerTab) {
122
126
  ensureSettingsTabsBound();
123
127
  const nextTab = normalizeTab(tab, SETTINGS_TABS) || storedSettingsTab();
124
128
  const nextProviderTab = normalizeTab(providerTab, SETTINGS_PROVIDER_TABS) || storedProviderTab();
129
+ const previousTab = document.querySelector('#settings-modal [data-settings-tab][aria-selected="true"]')?.dataset.settingsTab;
125
130
  if (applyTabState('data-settings-tab', 'data-settings-panel', nextTab)) {
126
131
  writeLocalStorage(SETTINGS_TAB_STORAGE_KEY, nextTab);
132
+ // A tab switched to re-reads what it shows (Relays: the relay name).
133
+ if (nextTab !== previousTab && typeof window !== 'undefined' && window.CustomEvent) {
134
+ window.dispatchEvent(new window.CustomEvent(SETTINGS_TAB_SHOWN_EVENT, { detail: { tab: nextTab } }));
135
+ }
127
136
  }
128
137
  if (applyTabState('data-settings-provider-tab', 'data-settings-provider-panel', nextProviderTab)) {
129
138
  writeLocalStorage(SETTINGS_PROVIDER_TAB_STORAGE_KEY, nextProviderTab);
@@ -72,6 +72,8 @@ import { applyAgentSdkState } from './agent-sdk-ui.js';
72
72
  import { renderUpdateSection } from './update-ui.js';
73
73
  import { applyHostSuspendState, applyRelayShutdownState } from './host-suspend-ui.js';
74
74
  import { applyClaudeUsageLimit, applyConversationBilling, applyUsageLimitPause } from './usage-limit-ui.js';
75
+ import { applyOpenAIImagePresets, applyOpenAIImageSpendState } from './openai-image-cost-ui.js';
76
+ import { OPENAI_IMAGE_PRESETS_EVENT, OPENAI_IMAGE_USAGE_EVENT } from './openai-image-spend.mjs';
75
77
  import {
76
78
  showThinking,
77
79
  removeThinking,
@@ -111,6 +113,12 @@ import { mergeRelayThoughts } from './relay-thoughts.mjs';
111
113
  import { conversationPayloadCache } from './conversation-payload-cache.mjs';
112
114
  import { planOnlyAfterBroadcast } from './plan-board-shared.mjs';
113
115
  import { noteConversationGone } from './session-history-ui.js';
116
+ import { handleImagePreview, handleImagePreviewReset, handleImageStarted } from './image-turn-placeholder.mjs';
117
+ import {
118
+ OPENAI_IMAGE_PREVIEW_EVENT,
119
+ OPENAI_IMAGE_PREVIEW_RESET_EVENT,
120
+ OPENAI_IMAGE_STARTED_EVENT,
121
+ } from './openai-image-composer.mjs';
114
122
 
115
123
  // A live row of the conversation on screen joins its kept window, so a
116
124
  // switch away and back still shows it. Rows of other conversations do not:
@@ -370,6 +378,9 @@ export async function connectSocket(overrideDeps) {
370
378
  setCliOnline(true);
371
379
  renderConvList();
372
380
  resyncAfterConnect();
381
+ // For views that load from the relay on their own (the media gallery's
382
+ // tiles): what failed while the relay was away can be asked for again.
383
+ window.dispatchEvent(new CustomEvent('oar:relay-connected'));
373
384
  // Every loaded page again, not page one only: events missed while the
374
385
  // socket was down touched rows anywhere in the list.
375
386
  scheduleLoadedConversationsRefresh();
@@ -442,6 +453,15 @@ export async function connectSocket(overrideDeps) {
442
453
  .catch(() => {});
443
454
  }
444
455
  });
456
+ // Today's OpenAI image spend after every booking and every change of the
457
+ // cap or the override (the banner, Settings, Check Usage), and the image
458
+ // presets after every change (the composer's select, Settings).
459
+ socket.on(OPENAI_IMAGE_USAGE_EVENT, (payload) => {
460
+ applyOpenAIImageSpendState(payload);
461
+ });
462
+ socket.on(OPENAI_IMAGE_PRESETS_EVENT, (payload) => {
463
+ applyOpenAIImagePresets(payload);
464
+ });
445
465
  // No view refresh here: the engine switch rebinds nothing — it only changes
446
466
  // which worker the NEXT Copilot conversation spawns.
447
467
  socket.on('copilot_settings_updated', (payload) => {
@@ -654,6 +674,12 @@ export async function connectSocket(overrideDeps) {
654
674
  if (sourceMessageId) clearRelayStreamStateForMessage(sourceMessageId);
655
675
  refreshSessionWorkerStatus().catch(() => {});
656
676
  });
677
+ // A running image turn of the open conversation: its placeholder in the
678
+ // live bubble (timer, size, the newest partials). Other conversations'
679
+ // events are ignored there.
680
+ socket.on(OPENAI_IMAGE_STARTED_EVENT, (payload) => handleImageStarted(payload));
681
+ socket.on(OPENAI_IMAGE_PREVIEW_EVENT, (payload) => handleImagePreview(payload));
682
+ socket.on(OPENAI_IMAGE_PREVIEW_RESET_EVENT, (payload) => handleImagePreviewReset(payload));
657
683
  // A folder's commit attribution changed (any client): an open context modal
658
684
  // re-reads it so its row and effect text match.
659
685
  socket.on('workspace_root_attribution_updated', () => {
@@ -5,6 +5,7 @@ import { resolveRelayDotState } from './relay-dot-state.mjs';
5
5
  import { readRepoBrowserPreferences } from './repo-browser-preferences.mjs';
6
6
  import { isChatInteractionHeld } from './selection-guard.mjs';
7
7
  import { nextSubagentStatus } from './subagent-fold.mjs';
8
+ import { useMathMarkdown } from './markdown-math.mjs';
8
9
  import { useStrictStrikethrough } from './markdown-strikethrough.mjs';
9
10
  import { parseGroupFoldChoices, pruneGroupFoldChoices } from './conversation-group-state.mjs';
10
11
  import { isPhoneLandscapeLayout, shouldCollapseListAfterPick, syncPhoneLandscapeAttribute } from './phone-landscape.mjs';
@@ -205,6 +206,7 @@ const CONVERSATION_SCROLL_STORAGE_PREFIX = 'copilot_message_scroll_';
205
206
  if (globalThis.marked && typeof globalThis.marked.setOptions === 'function') {
206
207
  globalThis.marked.setOptions({ breaks: true });
207
208
  useStrictStrikethrough(globalThis.marked);
209
+ useMathMarkdown(globalThis.marked);
208
210
  } else {
209
211
  console.warn('[store] marked unavailable; markdown rendering will use plain-text fallback.');
210
212
  }
@@ -1,4 +1,4 @@
1
- // "Stream view" (Settings › General › Appearance, and its mirror in the 🧠
1
+ // "Stream view" (Settings › Appearance, and its mirror in the 🧠
2
2
  // modal): an agent turn's thoughts, tool calls and reply as one list in the
3
3
  // order they happened, live and in the finished message. A setting of this
4
4
  // browser, kept in localStorage like the thoughts one; off unless switched
@@ -6,7 +6,7 @@
6
6
 
7
7
  import { FONT_SCALE_CHANGE_EVENT } from './font-scaling.js';
8
8
 
9
- export const TAB_STRIP_SELECTOR = '.settings-tab-strip, .plan-usage-tab-strip';
9
+ export const TAB_STRIP_SELECTOR = '.settings-tab-strip, .plan-usage-tab-strip, .context-tab-strip, .media-gallery-tabs, .media-gallery-chips';
10
10
 
11
11
  // Sub-pixel scroll ends (a zoomed page) count as the end.
12
12
  const EDGE_SLACK_PX = 1;
@@ -1,4 +1,4 @@
1
- // "Expand thoughts while an agent works" (Settings › General, and its mirror
1
+ // "Expand thoughts while an agent works" (Settings › Appearance, and its mirror
2
2
  // in the 🧠 modal). A setting of this browser, kept in localStorage like the
3
3
  // scroll-on-send one. On unless switched off: it decides whether a thought
4
4
  // starts open in the live turn, in a live sub-agent bubble and in the