@oar-sh/oar 0.9.23 → 0.9.25

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 (110) hide show
  1. package/.github/extensions/web-relay/skills/reasoning-stream.mjs +3 -1
  2. package/CHANGELOG.md +307 -0
  3. package/README.md +12 -11
  4. package/bin/oar.js +54 -4
  5. package/package.json +11 -7
  6. package/server/agent-sdk-loader.mjs +116 -0
  7. package/server/agent-sdks/claude/entry.mjs +4 -0
  8. package/server/agent-sdks/claude/package-lock.json +1438 -0
  9. package/server/agent-sdks/claude/package.json +13 -0
  10. package/server/agent-sdks/cursor/entry-sqlite.mjs +2 -0
  11. package/server/agent-sdks/cursor/entry.mjs +3 -0
  12. package/server/agent-sdks/cursor/package-lock.json +171 -0
  13. package/server/agent-sdks/cursor/package.json +11 -0
  14. package/server/agent-sdks/koffi/entry.cjs +2 -0
  15. package/server/agent-sdks/koffi/package-lock.json +368 -0
  16. package/server/agent-sdks/koffi/package.json +10 -0
  17. package/server/claude-worker/claude-preview-tool.mjs +5 -2
  18. package/server/claude-worker/claude-remote-relay-tool.mjs +20 -2
  19. package/server/claude-worker/claude-sdk-adapter.mjs +4 -1
  20. package/server/claude-worker/sdk-message-normalizer.mjs +29 -1
  21. package/server/copilot-worker/copilot-sdk-session-process.mjs +13 -8
  22. package/server/cursor-worker/cursor-sdk-adapter.mjs +5 -3
  23. package/server/cursor-worker/sdk-message-normalizer.mjs +13 -0
  24. package/server/db-schema.mjs +19 -0
  25. package/server/grok-worker/sdk-message-normalizer.mjs +3 -0
  26. package/server/migrations/0039-media-index.mjs +167 -0
  27. package/server/migrations/0040-image-session-defaults.mjs +93 -0
  28. package/server/public/app/agent-sdk-ui.js +349 -0
  29. package/server/public/app/api-client.js +149 -8
  30. package/server/public/app/ask-user-view.js +2 -1
  31. package/server/public/app/attachments-view.js +66 -9
  32. package/server/public/app/bootstrap.js +289 -94
  33. package/server/public/app/chat-actions-menu-state.mjs +1 -0
  34. package/server/public/app/context-media-tab.js +143 -0
  35. package/server/public/app/conversation-view.js +90 -5
  36. package/server/public/app/image-composer-ui.js +518 -0
  37. package/server/public/app/image-turn-placeholder.mjs +233 -0
  38. package/server/public/app/journal-view.js +12 -2
  39. package/server/public/app/media-gallery-model.mjs +202 -0
  40. package/server/public/app/media-gallery-ui.js +524 -0
  41. package/server/public/app/media-view.js +79 -0
  42. package/server/public/app/model-refresh-ui.mjs +413 -0
  43. package/server/public/app/no-conversation-state.mjs +5 -2
  44. package/server/public/app/openai-image-composer.mjs +492 -0
  45. package/server/public/app/openai-image-cost-ui.js +426 -0
  46. package/server/public/app/openai-image-spend.mjs +443 -0
  47. package/server/public/app/plan-usage-view.mjs +38 -6
  48. package/server/public/app/provider-readiness.mjs +67 -4
  49. package/server/public/app/relay-error-ctas.mjs +5 -0
  50. package/server/public/app/session-history-ui.js +2 -0
  51. package/server/public/app/settings-modal.js +171 -0
  52. package/server/public/app/socket-handlers.js +47 -0
  53. package/server/public/app/tab-strip-fade.mjs +1 -1
  54. package/server/public/app/viewer-gallery.mjs +23 -1
  55. package/server/public/index.html +478 -9
  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/agent-sdk-routes.mjs +73 -0
  60. package/server/routes/media-routes.mjs +361 -0
  61. package/server/routes/messages-routes.mjs +1467 -343
  62. package/server/routes/sessions-routes.mjs +596 -123
  63. package/server/server-runtime.mjs +414 -81
  64. package/server/services/about-service.mjs +26 -6
  65. package/server/services/agent-sdk-launch-gate.mjs +124 -0
  66. package/server/services/agent-sdk-paths.mjs +307 -0
  67. package/server/services/agent-sdk-store.mjs +1211 -0
  68. package/server/services/claude-api-settings-service.mjs +54 -17
  69. package/server/services/cli-install-service.mjs +3 -2
  70. package/server/services/copilot-model-discovery-service.mjs +7 -1
  71. package/server/services/database-file.mjs +23 -5
  72. package/server/services/deepseek-settings-service.mjs +58 -20
  73. package/server/services/earlier-database-service.mjs +4 -2
  74. package/server/services/generated-image-cleanup-service.mjs +2 -1
  75. package/server/services/generated-image-files.mjs +110 -0
  76. package/server/services/image-input-paths.mjs +389 -0
  77. package/server/services/image-operation-service.mjs +6 -2
  78. package/server/services/media-extract.mjs +653 -0
  79. package/server/services/media-index-service.mjs +221 -0
  80. package/server/services/model-refresh-scheduler.mjs +153 -0
  81. package/server/services/model-variant-catalog-service.mjs +141 -9
  82. package/server/services/oar-cli-helpers.mjs +72 -9
  83. package/server/services/oar-state-migration-service.mjs +2 -12
  84. package/server/services/oar-state-root.mjs +17 -0
  85. package/server/services/openai-image-preview-turns.mjs +330 -0
  86. package/server/services/openai-image-spend-service.mjs +516 -0
  87. package/server/services/openai-image-stream.mjs +170 -0
  88. package/server/services/plan-usage-openai-image.mjs +148 -0
  89. package/server/services/provider-model-refresh.mjs +326 -0
  90. package/server/services/provider-model-state.mjs +395 -0
  91. package/server/services/relay-singleton-guard.mjs +72 -10
  92. package/server/services/remote-relay-caller-context.mjs +6 -0
  93. package/server/services/remote-relay-dispatcher.mjs +386 -22
  94. package/server/services/remote-relay-registry-service.mjs +3 -1
  95. package/server/services/session-worker-launch-service.mjs +13 -0
  96. package/server/services/session-worker-supervisor-service.mjs +19 -1
  97. package/server/services/session-worker-websocket-service.mjs +15 -0
  98. package/server/services/token-usage-service.mjs +15 -0
  99. package/server/services/update-install-service.mjs +2 -2
  100. package/server/services/worker-launch-note-service.mjs +14 -2
  101. package/server/services/worker-recycle-service.mjs +145 -11
  102. package/shared/image-header.mjs +90 -0
  103. package/shared/model-refresh-schedule.mjs +120 -0
  104. package/shared/openai-image-options.mjs +299 -0
  105. package/shared/openai-image-presets.mjs +175 -0
  106. package/shared/openai-image-previews.mjs +40 -0
  107. package/shared/openai-image-prices.mjs +291 -0
  108. package/shared/openai-image-spend.mjs +90 -0
  109. package/shared/remote-relay-contract.mjs +126 -4
  110. package/shared/worker-runtime/direct-openai-image.mjs +98 -48
@@ -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 },
@@ -37,8 +37,16 @@ import {
37
37
  updateSubagentRecordRetentionSetting as requestSubagentRecordRetentionSetting,
38
38
  loadFeatureFlagSettings,
39
39
  updateFeatureFlagSetting as requestFeatureFlagSetting,
40
+ loadModelRefreshSettings,
41
+ updateModelRefreshSettings as requestModelRefreshSettings,
40
42
  requestRelayRestart,
41
43
  } from './api-client.js';
44
+ import {
45
+ formatModelRefreshIntervalLabel,
46
+ formatModelRefreshLastChecked,
47
+ nextModelRefreshExcluded,
48
+ normalizeModelRefreshSettings,
49
+ } from './model-refresh-ui.mjs';
42
50
  import { DEFAULT_BACKGROUND_TASK_TIMEOUT_MINUTES } from './background-task-timeout-options.mjs';
43
51
  import {
44
52
  DEFAULT_CLAUDE_BACKGROUND_COMMAND_LIMIT_MINUTES,
@@ -57,6 +65,7 @@ import { refreshClaudeAuthSection } from './claude-auth-ui.js';
57
65
  import { refreshGrokAuthSection } from './grok-auth-ui.js';
58
66
  import { syncDeepSeekSettingsInputs } from './deepseek-settings-ui.js';
59
67
  import { syncClaudeApiSettingsInputs } from './claude-api-settings-ui.js';
68
+ import { applyOpenAIImageSpendSettings, syncOpenAIImageSpendSettings } from './openai-image-cost-ui.js';
60
69
 
61
70
  export {
62
71
  applyDeepSeekSettingsState,
@@ -77,6 +86,7 @@ export {
77
86
  } from './claude-api-settings-ui.js';
78
87
  import { openClaudeCloudSettingsSection } from './claude-cloud-settings-ui.js';
79
88
  import { refreshCliInstallSections } from './cli-install-ui.js';
89
+ import { refreshAgentSdkRows } from './agent-sdk-ui.js';
80
90
  import { refreshUpdateSection } from './update-ui.js';
81
91
  import { refreshRemoteRelaysSection } from './remote-relays-settings.js';
82
92
  import { refreshAgentSessionsSection } from './agent-sessions-settings-ui.js';
@@ -399,6 +409,161 @@ export async function updateSubagentRecordRetentionSetting(value) {
399
409
  }
400
410
  }
401
411
 
412
+ // "Check for new models" (Settings › General › Models): a relay setting, on
413
+ // every 24 h by default, with one checkbox per enabled provider to leave it
414
+ // out (server/services/model-refresh-scheduler.mjs). Starts at the relay's
415
+ // defaults until its answer loads.
416
+ let modelRefreshSettings = normalizeModelRefreshSettings(null);
417
+ let modelRefreshUpdateInFlight = false;
418
+ // The provider list waits for the relay's answer: "Loading…" until one
419
+ // arrived, the reason while the last read failed.
420
+ let modelRefreshProvidersLoaded = false;
421
+ let modelRefreshLoadError = '';
422
+
423
+ function syncModelRefreshIntervalOptions(select) {
424
+ const { intervalChoices, intervalHours } = modelRefreshSettings;
425
+ // A stored value outside today's choices still shows as itself.
426
+ const choices = intervalChoices.includes(intervalHours)
427
+ ? intervalChoices
428
+ : [...intervalChoices, intervalHours].sort((a, b) => a - b);
429
+ const current = Array.from(select.options).map((option) => option.value).join(',');
430
+ if (current === choices.join(',')) return;
431
+ select.replaceChildren(...choices.map((hours) => {
432
+ const option = document.createElement('option');
433
+ option.value = String(hours);
434
+ option.textContent = formatModelRefreshIntervalLabel(hours);
435
+ return option;
436
+ }));
437
+ }
438
+
439
+ function renderModelRefreshProviderRows(list) {
440
+ const excluded = new Set(modelRefreshSettings.excluded);
441
+ const disabled = modelRefreshUpdateInFlight || !modelRefreshSettings.auto;
442
+ const now = Date.now();
443
+ const status = (text, className) => {
444
+ const line = document.createElement('div');
445
+ line.className = className;
446
+ line.textContent = text;
447
+ return line;
448
+ };
449
+ let statusLine = null;
450
+ if (modelRefreshLoadError) {
451
+ statusLine = status(`Could not load the providers: ${modelRefreshLoadError}`, 'settings-help model-refresh-providers-error');
452
+ } else if (!modelRefreshProvidersLoaded) {
453
+ statusLine = status('Loading…', 'settings-help model-refresh-providers-loading');
454
+ } else if (!modelRefreshSettings.providers.length) {
455
+ statusLine = status('No provider is switched on.', 'settings-help');
456
+ }
457
+ // After a failed read, the providers of an earlier answer stay below it.
458
+ const providers = modelRefreshProvidersLoaded ? modelRefreshSettings.providers : [];
459
+ list.replaceChildren(...(statusLine ? [statusLine] : []), ...providers.map((provider) => {
460
+ const inputId = `model-refresh-provider-${provider.id.toLowerCase().replace(/[^a-z0-9-]/g, '-')}`;
461
+ const row = document.createElement('div');
462
+ row.className = 'settings-row model-refresh-provider-row';
463
+ const label = document.createElement('label');
464
+ label.htmlFor = inputId;
465
+ const name = document.createElement('span');
466
+ name.textContent = `Check ${provider.label}`;
467
+ const last = document.createElement('span');
468
+ last.className = 'model-refresh-provider-last';
469
+ // An enabled provider without its key or CLI is not checked until it is.
470
+ last.textContent = provider.setUp ? formatModelRefreshLastChecked(provider.last, now) : 'Not set up';
471
+ label.append(name, last);
472
+ const input = document.createElement('input');
473
+ input.id = inputId;
474
+ input.type = 'checkbox';
475
+ input.dataset.modelRefreshProvider = provider.id;
476
+ input.setAttribute('aria-label', `Check ${provider.label} for new models`);
477
+ input.checked = !excluded.has(provider.id);
478
+ input.disabled = disabled;
479
+ input.addEventListener('change', () => {
480
+ void updateModelRefreshProviderSetting(provider.id, input.checked);
481
+ });
482
+ row.append(label, input);
483
+ return row;
484
+ }));
485
+ }
486
+
487
+ function syncModelRefreshSection() {
488
+ const toggle = document.getElementById('model-refresh-auto-toggle');
489
+ if (toggle instanceof HTMLInputElement) {
490
+ toggle.checked = modelRefreshSettings.auto;
491
+ toggle.disabled = modelRefreshUpdateInFlight;
492
+ }
493
+ const select = document.getElementById('model-refresh-interval-select');
494
+ if (select && select.tagName === 'SELECT') {
495
+ syncModelRefreshIntervalOptions(select);
496
+ select.value = String(modelRefreshSettings.intervalHours);
497
+ // Nothing to time while the check is off.
498
+ select.disabled = modelRefreshUpdateInFlight || !modelRefreshSettings.auto;
499
+ }
500
+ const list = document.getElementById('model-refresh-providers');
501
+ if (list) renderModelRefreshProviderRows(list);
502
+ }
503
+
504
+ export async function refreshModelRefreshSettings() {
505
+ // A new read: "Loading…" again until it answers, or the earlier list.
506
+ modelRefreshLoadError = '';
507
+ syncModelRefreshSection();
508
+ try {
509
+ const result = await loadModelRefreshSettings();
510
+ if (!result || typeof result !== 'object') throw new Error('The relay did not answer.');
511
+ modelRefreshSettings = normalizeModelRefreshSettings(result);
512
+ modelRefreshProvidersLoaded = true;
513
+ modelRefreshLoadError = '';
514
+ } catch (error) {
515
+ // Keep the last known value; the setting is not critical.
516
+ modelRefreshLoadError = String(error?.message || '').trim() || 'The relay did not answer.';
517
+ } finally {
518
+ syncModelRefreshSection();
519
+ }
520
+ }
521
+
522
+ async function saveModelRefreshSettings(patch, describe) {
523
+ if (modelRefreshUpdateInFlight) {
524
+ syncModelRefreshSection();
525
+ return;
526
+ }
527
+ modelRefreshUpdateInFlight = true;
528
+ syncModelRefreshSection();
529
+ try {
530
+ const result = await requestModelRefreshSettings(patch);
531
+ if (!result) throw new Error('Failed to update the check for new models.');
532
+ modelRefreshSettings = normalizeModelRefreshSettings(result);
533
+ modelRefreshProvidersLoaded = true;
534
+ modelRefreshLoadError = '';
535
+ showTransientRelayNotice(describe(modelRefreshSettings));
536
+ } catch (error) {
537
+ alert(error?.message || 'Failed to update the check for new models.');
538
+ } finally {
539
+ modelRefreshUpdateInFlight = false;
540
+ syncModelRefreshSection();
541
+ }
542
+ }
543
+
544
+ export function updateModelRefreshAutoSetting(checked) {
545
+ return saveModelRefreshSettings({ auto: checked === true }, (settings) => (settings.auto
546
+ ? `The relay checks for new models ${formatModelRefreshIntervalLabel(settings.intervalHours).toLowerCase()}.`
547
+ : 'The relay no longer checks for new models by itself.'));
548
+ }
549
+
550
+ export function updateModelRefreshIntervalSetting(value) {
551
+ return saveModelRefreshSettings({ intervalHours: Math.round(Number(value)) }, (settings) => (
552
+ `The relay checks for new models ${formatModelRefreshIntervalLabel(settings.intervalHours).toLowerCase()}.`
553
+ ));
554
+ }
555
+
556
+ export function updateModelRefreshProviderSetting(providerId, checked) {
557
+ const id = String(providerId || '').trim();
558
+ const label = modelRefreshSettings.providers.find((provider) => provider.id === id)?.label || id;
559
+ return saveModelRefreshSettings(
560
+ { excluded: nextModelRefreshExcluded(modelRefreshSettings.excluded, id, checked === true) },
561
+ (settings) => (settings.excluded.includes(id)
562
+ ? `${label} is no longer checked for new models.`
563
+ : `${label} is checked for new models.`),
564
+ );
565
+ }
566
+
402
567
  export async function updateBackgroundTaskTimeoutSetting(value) {
403
568
  if (backgroundTaskTimeoutUpdateInFlight) {
404
569
  syncBackgroundTaskTimeoutSlider();
@@ -478,6 +643,8 @@ function setOpenAISettingsControlsDisabled(disabled) {
478
643
 
479
644
  export function applyOpenAISettingsState(settings = {}, { resetInputs = false } = {}) {
480
645
  ensureOpenAISettingsInputTracking();
646
+ // The daily image cap, when the answer names it (openai-image-cost-ui.js).
647
+ if (typeof document !== 'undefined') applyOpenAIImageSpendSettings(settings || {});
481
648
  openAISettingsState = {
482
649
  configured: settings?.configured === true,
483
650
  enabled: settings?.configured === true && settings?.enabled === true,
@@ -1956,6 +2123,7 @@ export function openSettingsModal(tab, providerTab) {
1956
2123
  openAISettingsInputsDirty = false;
1957
2124
  ensureOpenAISettingsInputTracking();
1958
2125
  void syncOpenAISettingsInputs();
2126
+ void syncOpenAIImageSpendSettings();
1959
2127
  claudeSettingsInputsDirty = false;
1960
2128
  ensureClaudeSettingsInputTracking();
1961
2129
  void syncClaudeSettingsInputs();
@@ -1966,6 +2134,8 @@ export function openSettingsModal(tab, providerTab) {
1966
2134
  // in the meantime should show up the moment the panel opens. Sockets keep it
1967
2135
  // current from here on.
1968
2136
  void refreshCliInstallSections({ force: true });
2137
+ // The agent SDK rows: a read of the store's state, nothing spawned.
2138
+ void refreshAgentSdkRows();
1969
2139
  grokSettingsInputsDirty = false;
1970
2140
  ensureGrokSettingsInputTracking();
1971
2141
  void syncGrokSettingsInputs();
@@ -1989,6 +2159,7 @@ export function openSettingsModal(tab, providerTab) {
1989
2159
  void refreshRestartResumeSetting();
1990
2160
  syncSubagentRecordRetentionSelect();
1991
2161
  void refreshSubagentRecordRetentionSetting();
2162
+ void refreshModelRefreshSettings();
1992
2163
  void refreshRelayRestartMaxWaitSetting();
1993
2164
  void refreshPushSettingsSection();
1994
2165
  void refreshPreviewsSection();
@@ -31,7 +31,9 @@ import {
31
31
  refreshSummaryModal,
32
32
  summaryModalState,
33
33
  IS_SHARED_VIEW,
34
+ showTransientRelayNotice,
34
35
  } from './store.js';
36
+ import { newModelsToastFor } from './model-refresh-ui.mjs';
35
37
  import { scheduleContextUsageRefresh } from './api-client.js';
36
38
  import { capFinishedActivityEntries, capLiveActivityEntries, normalizeTurnSeq } from './activity-replay-state.mjs';
37
39
  import { liveActivityCap } from './live-activity-cap.mjs';
@@ -66,9 +68,12 @@ import { applyClaudeCloudSettingsState, refreshClaudeCloudSettingsState } from '
66
68
  import { applyClaudeCloudSessionEvent } from './claude-cloud-conversation-ui.js';
67
69
  import { updateProviderReadinessInputs } from './provider-readiness.mjs';
68
70
  import { applyCliInstallState } from './cli-install-ui.js';
71
+ import { applyAgentSdkState } from './agent-sdk-ui.js';
69
72
  import { renderUpdateSection } from './update-ui.js';
70
73
  import { applyHostSuspendState, applyRelayShutdownState } from './host-suspend-ui.js';
71
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';
72
77
  import {
73
78
  showThinking,
74
79
  removeThinking,
@@ -108,6 +113,12 @@ import { mergeRelayThoughts } from './relay-thoughts.mjs';
108
113
  import { conversationPayloadCache } from './conversation-payload-cache.mjs';
109
114
  import { planOnlyAfterBroadcast } from './plan-board-shared.mjs';
110
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';
111
122
 
112
123
  // A live row of the conversation on screen joins its kept window, so a
113
124
  // switch away and back still shows it. Rows of other conversations do not:
@@ -150,6 +161,7 @@ let deps = null;
150
161
  * @property {(force?: boolean) => (void | Promise<void>)} refreshModelCatalog
151
162
  * @property {(payload?: object) => void} updateModelCatalogState
152
163
  * @property {() => (void | Promise<void>)} reconcileOpenModelVariantModal
164
+ * @property {(tab?: string) => (void | Promise<void>)} [openSelectModelsModal]
153
165
  * @property {(payload?: object) => void} applyConversationWorkspaceRootUpdate
154
166
  * @property {(conversationId: string, title: string, updatedAt?: string | number | null) => void} applyConversationTitleUpdate
155
167
  * @property {() => void} syncChatTitleControls
@@ -366,6 +378,9 @@ export async function connectSocket(overrideDeps) {
366
378
  setCliOnline(true);
367
379
  renderConvList();
368
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'));
369
384
  // Every loaded page again, not page one only: events missed while the
370
385
  // socket was down touched rows anywhere in the list.
371
386
  scheduleLoadedConversationsRefresh();
@@ -414,6 +429,18 @@ export async function connectSocket(overrideDeps) {
414
429
  socket.on('models_updated', (payload) => {
415
430
  updateModelCatalogState(payload || {});
416
431
  void reconcileOpenModelVariantModal();
432
+ // A refresh that found models never seen before: one toast per event, on
433
+ // the clients connected now (the others get the menu dot and the tags).
434
+ const toast = newModelsToastFor(payload, { sharedView: IS_SHARED_VIEW });
435
+ if (toast) {
436
+ const { tab } = toast;
437
+ showTransientRelayNotice(toast.text, 10000, {
438
+ actionLabel: 'Show',
439
+ onAction: () => {
440
+ void Promise.resolve(deps?.openSelectModelsModal?.(tab)).catch(() => {});
441
+ },
442
+ });
443
+ }
417
444
  });
418
445
  // Each settings event also feeds provider readiness (provider-readiness.mjs),
419
446
  // so a Settings save changes the empty screen without a reload.
@@ -426,6 +453,15 @@ export async function connectSocket(overrideDeps) {
426
453
  .catch(() => {});
427
454
  }
428
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
+ });
429
465
  // No view refresh here: the engine switch rebinds nothing — it only changes
430
466
  // which worker the NEXT Copilot conversation spawns.
431
467
  socket.on('copilot_settings_updated', (payload) => {
@@ -457,6 +493,11 @@ export async function connectSocket(overrideDeps) {
457
493
  socket.on('cli_install_state', (payload) => {
458
494
  applyCliInstallState(payload || null);
459
495
  });
496
+ // Agent SDK installs, the same whole-buffer contract. It also feeds
497
+ // provider readiness (agent-sdk-ui.js passes the snapshot on).
498
+ socket.on('agent_sdk_state', (payload) => {
499
+ applyAgentSdkState(payload || null);
500
+ });
460
501
  // Self-update transitions AND npm log chunks; same whole-buffer contract as
461
502
  // cli_install_state, so a client connecting mid-update renders the full log.
462
503
  socket.on('update_state', (payload) => {
@@ -633,6 +674,12 @@ export async function connectSocket(overrideDeps) {
633
674
  if (sourceMessageId) clearRelayStreamStateForMessage(sourceMessageId);
634
675
  refreshSessionWorkerStatus().catch(() => {});
635
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));
636
683
  // A folder's commit attribution changed (any client): an open context modal
637
684
  // re-reads it so its row and effect text match.
638
685
  socket.on('workspace_root_attribution_updated', () => {
@@ -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;
@@ -32,7 +32,29 @@ export function galleryAt(gallery, index) {
32
32
 
33
33
  export function galleryPositionLabel(gallery) {
34
34
  if (!gallery || !Array.isArray(gallery.items) || !gallery.items.length) return '';
35
- return `${gallery.index + 1} / ${gallery.items.length}`;
35
+ return `${gallery.index + 1} / ${gallery.items.length}${galleryHasMore(gallery) ? '+' : ''}`;
36
+ }
37
+
38
+ // A paged gallery (the media gallery) knows only the pages loaded so far:
39
+ // `hasMore` says more items follow and `loadMore()` fetches the next page
40
+ // as `{ items, hasMore }`. Stepping past the last loaded item loads it.
41
+
42
+ export function galleryHasMore(gallery) {
43
+ return Boolean(gallery && gallery.hasMore && typeof gallery.loadMore === 'function');
44
+ }
45
+
46
+ /** Whether a step of `delta` can land somewhere: a neighbour, or a page still to load. */
47
+ export function galleryCanStep(gallery, delta) {
48
+ if (galleryNeighborIndex(gallery, delta) !== -1) return true;
49
+ return Number(delta) > 0 && galleryHasMore(gallery) && Number(gallery.index) === gallery.items.length - 1;
50
+ }
51
+
52
+ /** The gallery with a loaded page appended (items it already has are not added twice). */
53
+ export function extendGallery(gallery, page) {
54
+ if (!gallery) return null;
55
+ const known = new Set(gallery.items.map((item) => item.key).filter(Boolean));
56
+ const added = (Array.isArray(page?.items) ? page.items : []).filter((item) => !item?.key || !known.has(item.key));
57
+ return { ...gallery, items: [...gallery.items, ...added], hasMore: Boolean(page?.hasMore) && added.length > 0 };
36
58
  }
37
59
 
38
60
  /** The items on either side of the open one, nearest first. */