@oar-sh/oar 0.9.24 → 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 (66) hide show
  1. package/.github/extensions/web-relay/skills/reasoning-stream.mjs +3 -1
  2. package/CHANGELOG.md +142 -0
  3. package/README.md +12 -11
  4. package/package.json +3 -1
  5. package/server/claude-worker/claude-remote-relay-tool.mjs +15 -0
  6. package/server/claude-worker/sdk-message-normalizer.mjs +29 -1
  7. package/server/copilot-worker/copilot-sdk-session-process.mjs +11 -9
  8. package/server/cursor-worker/sdk-message-normalizer.mjs +13 -0
  9. package/server/db-schema.mjs +19 -0
  10. package/server/grok-worker/sdk-message-normalizer.mjs +3 -0
  11. package/server/migrations/0039-media-index.mjs +167 -0
  12. package/server/migrations/0040-image-session-defaults.mjs +93 -0
  13. package/server/public/app/api-client.js +58 -0
  14. package/server/public/app/ask-user-view.js +2 -1
  15. package/server/public/app/attachments-view.js +66 -9
  16. package/server/public/app/bootstrap.js +85 -25
  17. package/server/public/app/chat-actions-menu-state.mjs +1 -0
  18. package/server/public/app/context-media-tab.js +143 -0
  19. package/server/public/app/conversation-view.js +90 -5
  20. package/server/public/app/image-composer-ui.js +518 -0
  21. package/server/public/app/image-turn-placeholder.mjs +233 -0
  22. package/server/public/app/journal-view.js +12 -2
  23. package/server/public/app/media-gallery-model.mjs +202 -0
  24. package/server/public/app/media-gallery-ui.js +524 -0
  25. package/server/public/app/media-view.js +79 -0
  26. package/server/public/app/openai-image-composer.mjs +492 -0
  27. package/server/public/app/openai-image-cost-ui.js +426 -0
  28. package/server/public/app/openai-image-spend.mjs +443 -0
  29. package/server/public/app/plan-usage-view.mjs +38 -6
  30. package/server/public/app/relay-error-ctas.mjs +5 -0
  31. package/server/public/app/session-history-ui.js +2 -0
  32. package/server/public/app/settings-modal.js +4 -0
  33. package/server/public/app/socket-handlers.js +26 -0
  34. package/server/public/app/tab-strip-fade.mjs +1 -1
  35. package/server/public/app/viewer-gallery.mjs +23 -1
  36. package/server/public/index.html +383 -7
  37. package/server/relay-tools.md +1 -0
  38. package/server/repositories/image-conversation-repository.mjs +69 -0
  39. package/server/repositories/media-repository.mjs +305 -0
  40. package/server/routes/media-routes.mjs +361 -0
  41. package/server/routes/messages-routes.mjs +1410 -360
  42. package/server/routes/sessions-routes.mjs +361 -14
  43. package/server/server-runtime.mjs +19 -0
  44. package/server/services/database-file.mjs +6 -4
  45. package/server/services/generated-image-cleanup-service.mjs +2 -1
  46. package/server/services/generated-image-files.mjs +110 -0
  47. package/server/services/image-input-paths.mjs +389 -0
  48. package/server/services/image-operation-service.mjs +6 -2
  49. package/server/services/media-extract.mjs +653 -0
  50. package/server/services/media-index-service.mjs +221 -0
  51. package/server/services/openai-image-preview-turns.mjs +330 -0
  52. package/server/services/openai-image-spend-service.mjs +516 -0
  53. package/server/services/openai-image-stream.mjs +170 -0
  54. package/server/services/plan-usage-openai-image.mjs +148 -0
  55. package/server/services/remote-relay-caller-context.mjs +6 -0
  56. package/server/services/remote-relay-dispatcher.mjs +386 -22
  57. package/server/services/remote-relay-registry-service.mjs +3 -1
  58. package/server/services/token-usage-service.mjs +15 -0
  59. package/shared/image-header.mjs +90 -0
  60. package/shared/openai-image-options.mjs +299 -0
  61. package/shared/openai-image-presets.mjs +175 -0
  62. package/shared/openai-image-previews.mjs +40 -0
  63. package/shared/openai-image-prices.mjs +291 -0
  64. package/shared/openai-image-spend.mjs +90 -0
  65. package/shared/remote-relay-contract.mjs +126 -4
  66. package/shared/worker-runtime/direct-openai-image.mjs +96 -50
@@ -0,0 +1,93 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Migration: an image session's own defaults, and a turn's options.
5
+ *
6
+ * `image_sessions.defaults_json`: the size, quality, count, output format and
7
+ * background an image session's turns use unless a turn names its own
8
+ * (relay_tool update_session `image`, the conversation settings route).
9
+ * JSON as shared/openai-image-options.mjs validates it; NULL means none.
10
+ *
11
+ * `queue.image_options_json`: the options of one image turn as the send
12
+ * settled them, for a turn without an image operation (the worker sends them
13
+ * with its direct request). NULL for every other turn.
14
+ *
15
+ * The image tables are created by migrateImageConversationSchema
16
+ * (repositories/image-conversation-repository.mjs), which this migration
17
+ * calls first, additively, so a database whose tables are missing gets them
18
+ * before the column. A database without the queue table (no relay's) is
19
+ * left alone.
20
+ *
21
+ * Additive and idempotent. Safe to run on every boot. Rolling back needs no
22
+ * data change: older code ignores the column.
23
+ *
24
+ * Usage: node server/migrations/0040-image-session-defaults.mjs [path/to/oar.db]
25
+ */
26
+
27
+ import { fileURLToPath } from 'url';
28
+ import path from 'path';
29
+ import process from 'process';
30
+ import Database from 'better-sqlite3';
31
+ import { cliDbPath } from '../services/database-file.mjs';
32
+ import { migrateImageConversationSchema } from '../repositories/image-conversation-repository.mjs';
33
+
34
+ export function hasImageSessionDefaultsColumn(db) {
35
+ try {
36
+ return db.prepare(`PRAGMA table_info(image_sessions)`).all().some((column) => column.name === 'defaults_json');
37
+ } catch {
38
+ return false;
39
+ }
40
+ }
41
+
42
+ function tableExists(db, name) {
43
+ return !!db.prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`).get(name);
44
+ }
45
+
46
+ export function hasQueueImageOptionsColumn(db) {
47
+ try {
48
+ return db.prepare(`PRAGMA table_info(queue)`).all().some((column) => column.name === 'image_options_json');
49
+ } catch {
50
+ return false;
51
+ }
52
+ }
53
+
54
+ export function migrateImageSessionDefaults(db) {
55
+ // The image schema reads the queue; a database without one is no relay's.
56
+ if (!tableExists(db, 'queue')) return { applied: false, columnsAdded: 0 };
57
+ migrateImageConversationSchema(db);
58
+ let columnsAdded = 0;
59
+ if (!hasImageSessionDefaultsColumn(db)) {
60
+ db.exec(`ALTER TABLE image_sessions ADD COLUMN defaults_json TEXT`);
61
+ columnsAdded += 1;
62
+ }
63
+ if (!hasQueueImageOptionsColumn(db)) {
64
+ db.exec(`ALTER TABLE queue ADD COLUMN image_options_json TEXT`);
65
+ columnsAdded += 1;
66
+ }
67
+ return { applied: columnsAdded > 0, columnsAdded };
68
+ }
69
+
70
+ export function migrate(dbPath, options = {}) {
71
+ const db = new Database(dbPath, options);
72
+ try {
73
+ return migrateImageSessionDefaults(db);
74
+ } finally {
75
+ db.close();
76
+ }
77
+ }
78
+
79
+ const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
80
+ if (isMain) {
81
+ try {
82
+ // The given path, else the database in server/data (oar.db, else the
83
+ // name before 0.9.20); never a new one.
84
+ const dbPath = cliDbPath(process.argv[2], new URL('..', import.meta.url));
85
+ const result = migrate(dbPath);
86
+ console.log(`Migration 0040 on ${dbPath}`);
87
+ console.log(result.applied ? ` image session defaults, turn options: columns added ${result.columnsAdded}` : ' skipped: nothing to change');
88
+ process.exit(0);
89
+ } catch (err) {
90
+ console.error(`Migration 0040 failed: ${err?.message || err}`);
91
+ process.exit(1);
92
+ }
93
+ }
@@ -1,5 +1,6 @@
1
1
  import { BASE, TOKEN, CLIENT_ID, IS_SHARE_PREVIEW, SHARE_PREVIEW_CONVERSATION_ID, SHARED_CONVERSATION_TOKEN, authHeaders, updateWorkspaceRootHints, applyContextUsageBar, readContextUsageRatio, currentConvId, conversations, setCliOnline, setCloudflaredTunnelState, setActiveRuntimeSessionCount, setRuntimeSessionBindingCount, setContextIndicatorMode, setServerPlatform, setUpdateState } from './store.js';
2
2
  import { liveActivityCap } from './live-activity-cap.mjs';
3
+ import { OPENAI_IMAGE_PRESETS_PATH, OPENAI_IMAGE_SETTINGS_FIELDS, OPENAI_IMAGE_USAGE_PATH } from './openai-image-spend.mjs';
3
4
 
4
5
  // fetch() has no default timeout. Without one, a request issued while the app is
5
6
  // being backgrounded can hang indefinitely on a half-open mobile connection and
@@ -543,6 +544,63 @@ export async function updateOpenAISettings({
543
544
  return settingsRequest('/api/settings/openai', payload, 'Failed to update OpenAI settings');
544
545
  }
545
546
 
547
+ /**
548
+ * The daily image cap and today's override (POST /api/settings/openai with
549
+ * only these fields). Throws with the relay's words: a paired relay's
550
+ * request is refused (403).
551
+ */
552
+ export async function updateOpenAIImageSpendSettings({ imageDailyCapUsd = undefined, allowImagesToday = undefined } = {}) {
553
+ const payload = {};
554
+ if (imageDailyCapUsd !== undefined) payload[OPENAI_IMAGE_SETTINGS_FIELDS.dailyCapUsd] = Number(imageDailyCapUsd);
555
+ if (typeof allowImagesToday === 'boolean') payload[OPENAI_IMAGE_SETTINGS_FIELDS.allowToday] = allowImagesToday;
556
+ return settingsRequest('/api/settings/openai', payload, 'Failed to update the image cap');
557
+ }
558
+
559
+ /** Today's image spend; with a conversation, that chat's cost too. Null on any failure. */
560
+ export async function loadOpenAIImageUsage({ conversationId = '' } = {}) {
561
+ const id = String(conversationId || '').trim();
562
+ return quietGet(id ? `${OPENAI_IMAGE_USAGE_PATH}?conversationId=${encodeURIComponent(id)}` : OPENAI_IMAGE_USAGE_PATH);
563
+ }
564
+
565
+ // The presets routes: the relay's `error` and `code` ride on the thrown Error.
566
+ async function imagePresetsRequest(method, path, body = null) {
567
+ if (!networkRequestsEnabled) return null;
568
+ let response;
569
+ try {
570
+ response = await fetch(`${BASE}${path}`, {
571
+ signal: requestTimeoutSignal(),
572
+ method,
573
+ headers: { 'Content-Type': 'application/json', ...authHeaders() },
574
+ ...(body ? { body: JSON.stringify(body) } : {}),
575
+ });
576
+ } catch (error) {
577
+ noteFetchFailure(path, error);
578
+ throw Object.assign(new Error(`The relay could not be reached (${readableServerError(toErrorMessage(error))}).`), { status: 0, code: null });
579
+ }
580
+ noteFetchSuccess();
581
+ const result = await response.json().catch(() => null);
582
+ if (!response.ok) {
583
+ throw Object.assign(
584
+ new Error(readableServerError(result?.error || '') || `The relay answered HTTP ${response.status}.`),
585
+ { status: response.status, code: result?.code || null },
586
+ );
587
+ }
588
+ return result;
589
+ }
590
+
591
+ /** `{ presets }` (built-ins and own), or null when the relay has none to give. */
592
+ export async function loadOpenAIImagePresets() {
593
+ return quietGet(OPENAI_IMAGE_PRESETS_PATH);
594
+ }
595
+
596
+ export async function saveOpenAIImagePreset(preset, { replace = false } = {}) {
597
+ return imagePresetsRequest('PUT', OPENAI_IMAGE_PRESETS_PATH, { preset, ...(replace ? { replace: true } : {}) });
598
+ }
599
+
600
+ export async function deleteOpenAIImagePreset(name) {
601
+ return imagePresetsRequest('DELETE', `${OPENAI_IMAGE_PRESETS_PATH}/${encodeURIComponent(String(name || ''))}`);
602
+ }
603
+
546
604
  export async function loadCursorSettings() {
547
605
  return apiFetch('/api/settings/cursor');
548
606
  }
@@ -131,7 +131,8 @@ export async function loadRelayQuestions(conversationId) {
131
131
  // after a link the relay did not know: its notice keeps the screen empty
132
132
  // until the reader opens something.
133
133
  const readingAView = document.body?.classList?.contains('inbox-view-active')
134
- || document.body?.classList?.contains('status-view-active');
134
+ || document.body?.classList?.contains('status-view-active')
135
+ || document.body?.classList?.contains('media-view-active');
135
136
  if (!conversationId && pending.length && !readingAView && !isConversationAutoOpenHeld()) {
136
137
  const latest = pending
137
138
  .slice()
@@ -81,6 +81,8 @@ import {
81
81
  galleryNeighborIndex,
82
82
  galleryNeighbors,
83
83
  galleryPositionLabel,
84
+ galleryCanStep,
85
+ extendGallery,
84
86
  } from './viewer-gallery.mjs';
85
87
  import {
86
88
  deepestExistingAncestor,
@@ -672,9 +674,10 @@ function updateFilePreviewUiState() {
672
674
  if (bottomChrome) {
673
675
  bottomChrome.hidden = !gallery;
674
676
  document.getElementById('file-preview-position').textContent = galleryPositionLabel(gallery);
675
- document.getElementById('file-preview-prev').disabled = galleryNeighborIndex(gallery, -1) === -1;
676
- document.getElementById('file-preview-next').disabled = galleryNeighborIndex(gallery, 1) === -1;
677
+ document.getElementById('file-preview-prev').disabled = !galleryCanStep(gallery, -1);
678
+ document.getElementById('file-preview-next').disabled = !galleryCanStep(gallery, 1);
677
679
  }
680
+ renderGalleryDetails(gallery);
678
681
  }
679
682
 
680
683
  /**
@@ -1231,7 +1234,7 @@ export function openUploadedAttachmentViewer(name, contentUrl, mimeType, options
1231
1234
  kind: uploadPreviewKind(mimeType),
1232
1235
  name: String(name || 'attachment'),
1233
1236
  rawUrl: String(contentUrl || ''),
1234
- size: 0,
1237
+ size: Number(options?.size) > 0 ? Number(options.size) : 0,
1235
1238
  contentType: String(mimeType || '').toLowerCase(),
1236
1239
  },
1237
1240
  });
@@ -1249,7 +1252,7 @@ export function openGalleryItem(item, gallery) {
1249
1252
  if (!item) return Promise.resolve(false);
1250
1253
  const source = item.source || gallery?.source || 'workspace';
1251
1254
  if (source === 'upload') {
1252
- openUploadedAttachmentViewer(item.name, item.href, item.mime, { gallery });
1255
+ openUploadedAttachmentViewer(item.name, item.href, item.mime, { gallery, size: item.size });
1253
1256
  return Promise.resolve(true);
1254
1257
  }
1255
1258
  const open = source === 'workspace' ? openWorkspaceFilePreview : openDriveFilePreview;
@@ -1335,7 +1338,9 @@ export function renderFilePreview() {
1335
1338
  const kindLabel = String(payload.kind || 'text').toUpperCase();
1336
1339
  const langLabel = payload.language ? ` · ${payload.language}` : '';
1337
1340
  const truncatedLabel = payload.truncated ? ` · truncated to ${formatBytes(FILE_PREVIEW_MAX_BYTES)}` : '';
1338
- metaEl.textContent = `${kindLabel}${langLabel} · ${formatBytes(payload.size || 0)}${truncatedLabel}`;
1341
+ // An attachment opened by its address may come without a size: no "0 B" then.
1342
+ const sizeLabel = payload.size > 0 || filePreviewState.source !== 'upload' ? ` · ${formatBytes(payload.size || 0)}` : '';
1343
+ metaEl.textContent = `${kindLabel}${langLabel}${sizeLabel}${truncatedLabel}`;
1339
1344
 
1340
1345
  const cardMeta = payload.size > 0 ? formatBytes(payload.size) : '';
1341
1346
 
@@ -1560,13 +1565,65 @@ export async function openWorkspaceFilePreviewFromRepo(rawPath, options = {}) {
1560
1565
 
1561
1566
  /** Show the file `delta` steps along the gallery; false at either end. */
1562
1567
  export async function stepFilePreviewGallery(delta) {
1563
- const gallery = filePreviewState.gallery;
1564
- const index = galleryNeighborIndex(gallery, delta);
1568
+ let gallery = filePreviewState.gallery;
1569
+ let index = galleryNeighborIndex(gallery, delta);
1570
+ if (index === -1 && galleryCanStep(gallery, delta)) {
1571
+ // A paged gallery: the next page is fetched and the step goes on into it.
1572
+ let page = null;
1573
+ try {
1574
+ page = await gallery.loadMore();
1575
+ } catch {
1576
+ page = null;
1577
+ }
1578
+ if (filePreviewState.gallery !== gallery) return false;
1579
+ gallery = extendGallery(gallery, page || { items: [], hasMore: false });
1580
+ filePreviewState.gallery = gallery;
1581
+ index = galleryNeighborIndex(gallery, delta);
1582
+ if (index === -1) {
1583
+ renderFilePreview();
1584
+ return false;
1585
+ }
1586
+ }
1565
1587
  if (index === -1) return false;
1566
1588
  const next = galleryAt(gallery, index);
1567
1589
  return openGalleryItem(next.items[index], next);
1568
1590
  }
1569
1591
 
1592
+ // What a gallery says about its open item (the media gallery: session,
1593
+ // date, who sent it) and the buttons it adds ("Open in session", "Copy
1594
+ // path"), built here so the viewer markup stays the same for every gallery.
1595
+ function renderGalleryDetails(gallery) {
1596
+ const toolbar = document.querySelector('#file-preview-chrome-top .file-preview-toolbar');
1597
+ let caption = document.getElementById('file-preview-gallery-caption');
1598
+ if (!caption && toolbar) {
1599
+ caption = document.createElement('div');
1600
+ caption.id = 'file-preview-gallery-caption';
1601
+ caption.className = 'file-preview-meta';
1602
+ toolbar.insertAdjacentElement('afterend', caption);
1603
+ }
1604
+ document.querySelectorAll('[data-gallery-action]').forEach((button) => button.remove());
1605
+ const item = gallery?.items?.[gallery.index];
1606
+ const details = item && typeof gallery.describe === 'function' ? gallery.describe(item) : null;
1607
+ if (caption) {
1608
+ caption.textContent = details?.caption || '';
1609
+ caption.hidden = !details?.caption;
1610
+ }
1611
+ if (!toolbar || !Array.isArray(details?.actions)) return;
1612
+ const anchor = document.getElementById('file-preview-copy-btn');
1613
+ for (const action of details.actions) {
1614
+ const button = document.createElement('button');
1615
+ button.type = 'button';
1616
+ button.className = 'file-preview-btn';
1617
+ button.textContent = action.label;
1618
+ button.dataset.galleryAction = action.id;
1619
+ button.addEventListener('click', () => {
1620
+ viewerControls.interaction();
1621
+ void action.run(item);
1622
+ });
1623
+ toolbar.insertBefore(button, anchor || null);
1624
+ }
1625
+ }
1626
+
1570
1627
  // Preview payloads of the open file's neighbours, fetched ahead so a swipe
1571
1628
  // lands on the file and not on "Fetching…"; an image's bytes are asked for
1572
1629
  // too, under the very address the viewer will use. Entries are promises.
@@ -2935,8 +2992,8 @@ const viewerSwipe = createSwipeRecognizer({
2935
2992
  return;
2936
2993
  }
2937
2994
  // Past either end the content gives a little, then holds.
2938
- const toward = galleryNeighborIndex(filePreviewState.gallery, dx < 0 ? 1 : -1);
2939
- const shift = toward === -1 ? dx * 0.25 : dx;
2995
+ // A page still to load counts as somewhere to go.
2996
+ const shift = galleryCanStep(filePreviewState.gallery, dx < 0 ? 1 : -1) ? dx : dx * 0.25;
2940
2997
  bodyEl.style.transform = `translateX(${shift}px)`;
2941
2998
  },
2942
2999
  onEnd: ({ axis, swipe }) => {
@@ -161,6 +161,7 @@ import { loadKeepingLiveRows } from './transcript-reload-freshness.mjs';
161
161
  import { backgroundTasksSnapshotMark } from './background-tasks-view.mjs';
162
162
  import { newerPageReachesEnd } from './transcript-island.mjs';
163
163
  import { initPinnedMessagesView, openPinnedMessagesModal, setPinsConversation } from './pinned-messages-view.mjs';
164
+ import { afterContextRender, initContextTabs, leaveContextTab, resetContextTab, wrapContextBody } from './context-media-tab.js';
164
165
  import { initHiddenMessagesView, openHiddenMessagesModal, setHiddenMessagesConversation } from './hidden-messages-view.mjs';
165
166
  import { bindChatSelectionGuard, chatSelectionGuard, isChatInteractionHeld } from './selection-guard.mjs';
166
167
  import { loadRepoBrowserTree, openRepoBrowser, closeRepoBrowser, setRepoBrowserSessionInfo, resetWorkspaceRepoBrowserForRootChange } from './attachments-view.js';
@@ -221,6 +222,13 @@ import {
221
222
  sessionLockNoteText,
222
223
  sessionLockProviderKey,
223
224
  } from './conversation-provider-indicator.mjs';
225
+ import { imageQualityOptions } from './openai-image-composer.mjs';
226
+ import { hideImageComposerControls, syncImageComposerControls } from './image-composer-ui.js';
227
+ import {
228
+ onOpenAIImageSpendChange,
229
+ syncChatMenuImageCost,
230
+ syncOpenAIImageSpendBanner,
231
+ } from './openai-image-cost-ui.js';
224
232
  import {
225
233
  initMessageSearchView,
226
234
  openMessageSearchModal,
@@ -278,6 +286,7 @@ import { closeQuestionsPanel, initQuestionsPanel, openQuestionsPanel, setQuestio
278
286
  import { questionsTwinLabel } from './questions-view.mjs';
279
287
  import { closeRelaySchedulePanel } from './relay-schedules-ui.js';
280
288
  import { isInboxViewActive } from './inbox-ui.js';
289
+ import { isMediaViewActive, leaveMediaView, toggleMediaView } from './media-view.js';
281
290
  import { initScrollbarHover } from './scrollbar-hover.mjs';
282
291
  import {
283
292
  autoCompactWindowNotes,
@@ -297,11 +306,11 @@ import {
297
306
  autoCompactWindowFromIndex,
298
307
  formatAutoCompactWindowLabel,
299
308
  } from './auto-compact-window-options.mjs';
300
- import { renderPlanUsageHtml, planUsageSubtitle, normalizeUsageProvider } from './plan-usage-view.mjs';
309
+ import { renderPlanUsageHtml, renderPlanUsageCardHtml, planUsageSubtitle, normalizeUsageProvider } from './plan-usage-view.mjs';
301
310
  import { revealSelectedTabs, watchTabStripFades } from './tab-strip-fade.mjs';
302
311
  import { FONT_SCALE_CHANGE_EVENT, getUiScale, initFontScaling, updateFontScaleFromSelect } from './font-scaling.js';
303
312
  import { initClientDiagnostics, recordStatusEvent } from './status-store.mjs';
304
- import { isStatusViewActive, toggleStatusView } from './status-view.mjs';
313
+ import { isStatusViewActive, leaveStatusView, toggleStatusView } from './status-view.mjs';
305
314
  import { installExternalLinkPolicy, openExternalNavigation } from './external-link-policy.mjs';
306
315
  import {
307
316
  initCwdPicker,
@@ -1659,13 +1668,6 @@ function modelProvidersForId(modelId, providersByModel = {}) {
1659
1668
  return providers.map((provider) => String(provider || '').trim().toLowerCase()).filter(Boolean);
1660
1669
  }
1661
1670
 
1662
- function openAIImageSizesForModel(modelId = '') {
1663
- const normalized = String(modelId || '').trim().toLowerCase().replace(/^openai\//, '');
1664
- if (normalized.startsWith('dall-e-2')) return ['256x256', '512x512', '1024x1024'];
1665
- if (normalized.startsWith('dall-e-3')) return ['1024x1024', '1792x1024', '1024x1792'];
1666
- return ['auto', '1024x1024', '1536x1024', '1024x1536'];
1667
- }
1668
-
1669
1671
  function modelVisibleForActiveProvider(modelId, activeProviderType, providersByModel = {}) {
1670
1672
  const normalizedModelId = String(modelId || '').trim().toLowerCase();
1671
1673
  if (!normalizedModelId) return false;
@@ -1799,8 +1801,12 @@ function selectedReasoningEffortValue() {
1799
1801
  function updateReasoningSelectorForModel(modelId, preferredEffort = '', { persist = false } = {}) {
1800
1802
  const select = document.getElementById('reasoning-effort-select');
1801
1803
  if (!select) return;
1802
- select.title = isOpenAIImageModelId(modelId) ? 'Quality' : 'Reasoning effort';
1803
- const options = reasoningOptionsForModel(modelId);
1804
+ const imageModel = isOpenAIImageModelId(modelId);
1805
+ select.title = imageModel ? 'Quality' : 'Reasoning effort';
1806
+ // An image model's Quality: the catalog's values its family takes.
1807
+ const options = imageModel
1808
+ ? imageQualityOptions(modelId, reasoningOptionsForModel(modelId))
1809
+ : reasoningOptionsForModel(modelId);
1804
1810
  const currentEffort = String(select.value || '').trim().toLowerCase();
1805
1811
  const storedEffort = String(localStorage.getItem(REASONING_STORAGE_KEY) || '').trim().toLowerCase();
1806
1812
  select.innerHTML = '';
@@ -2082,20 +2088,16 @@ function updateContextTierSelector(modelId) {
2082
2088
  const select = document.getElementById('context-tier-select');
2083
2089
  if (!select) return;
2084
2090
  if (isOpenAIImageModelId(modelId)) {
2085
- const current = String(select.value || '').trim().toLowerCase();
2086
- const imageSizes = openAIImageSizesForModel(modelId);
2087
- select.innerHTML = '';
2088
- for (const size of imageSizes) {
2089
- const option = document.createElement('option');
2090
- option.value = size;
2091
- option.textContent = size;
2092
- select.appendChild(option);
2093
- }
2094
- select.value = imageSizes.includes(current) ? current : 'auto';
2095
- select.title = 'Image size';
2091
+ // Size (with Custom…), Count, Format and Background: image-composer-ui.js,
2092
+ // from the conversation's stored values.
2093
+ syncImageComposerControls(modelId, currentConvId);
2094
+ // Today's image spend near or at the daily cap: the banner of an image chat.
2095
+ syncOpenAIImageSpendBanner({ imageChat: true });
2096
2096
  updateModelPricingDetails(modelId, 'default');
2097
2097
  return;
2098
2098
  }
2099
+ hideImageComposerControls();
2100
+ syncOpenAIImageSpendBanner({ imageChat: false });
2099
2101
  const tierOptions = buildContextTierOptions({
2100
2102
  modelId,
2101
2103
  providerType: activeComposerProviderType(),
@@ -3454,6 +3456,27 @@ function usageCardIsFresh(provider) {
3454
3456
  return Number.isFinite(at) && Date.now() - at < USAGE_LIVE_FRESH_MS;
3455
3457
  }
3456
3458
 
3459
+ /**
3460
+ * A new OpenAI image spend state (the socket event): the kept report's
3461
+ * 'openai' card takes it, and the open modal redraws that card alone.
3462
+ */
3463
+ function applyImageSpendToUsageReport(state) {
3464
+ if (!state || !Array.isArray(lastUsageReport?.providers)) return;
3465
+ const index = lastUsageReport.providers.findIndex((card) => card?.imageSpend && normalizeUsageProvider(card.provider) === 'openai');
3466
+ if (index < 0) return;
3467
+ const card = { ...lastUsageReport.providers[index], imageSpend: { ...lastUsageReport.providers[index].imageSpend, ...state } };
3468
+ const providers = lastUsageReport.providers.slice();
3469
+ providers[index] = card;
3470
+ storeUsageReport({ ...lastUsageReport, providers });
3471
+ if (summaryModalState.kind !== 'usage') return;
3472
+ const panel = document.getElementById('summary-modal-body')?.querySelector('[data-usage-panel="openai"]');
3473
+ if (!panel) return;
3474
+ const updating = panel.hasAttribute('data-updating');
3475
+ panel.outerHTML = renderPlanUsageCardHtml(card, { active: !panel.hidden });
3476
+ if (updating) markUsageCardUpdating('openai', true);
3477
+ }
3478
+ onOpenAIImageSpendChange(applyImageSpendToUsageReport);
3479
+
3457
3480
  /** The "Updating…" marker on one card, while its live read runs. */
3458
3481
  function markUsageCardUpdating(provider, updating) {
3459
3482
  const body = document.getElementById('summary-modal-body');
@@ -4036,11 +4059,13 @@ async function loadContextSummaryAndRender(convId) {
4036
4059
  const detailText = String(payload.text || '').trim();
4037
4060
  // The structured breakdown is the answer; the runtime's own text dump stays
4038
4061
  // available underneath for the details it carries that categories don't.
4039
- const bodyHtml = usageHtml
4062
+ const contextHtml = usageHtml
4040
4063
  ? `${usageHtml}${autoCompactHtml}${thinkingHtml}${thoughtsHtml}${attributionHtml}${agentSessionsHtml}${detailText
4041
4064
  ? `<details class="ctx-usage-raw"><summary>Raw details</summary><pre>${escHtml(detailText)}</pre></details>`
4042
4065
  : ''}`
4043
4066
  : `${autoCompactHtml}${thinkingHtml}${thoughtsHtml}${attributionHtml}${agentSessionsHtml}<pre>${escHtml(detailText || 'No context data available.')}</pre>`;
4067
+ // Two categories: Context (this body) and the session's Media gallery.
4068
+ const bodyHtml = wrapContextBody(contextHtml, settingsConversationId);
4044
4069
 
4045
4070
  renderSummaryModalContent({
4046
4071
  title: 'Context usage',
@@ -4049,7 +4074,11 @@ async function loadContextSummaryAndRender(convId) {
4049
4074
  refresh: () => loadContextSummaryAndRender(refreshLookupId),
4050
4075
  kind: 'context',
4051
4076
  });
4052
- setSummaryModalLeaveHandler('context', () => commitAutoCompactDraft());
4077
+ setSummaryModalLeaveHandler('context', () => {
4078
+ commitAutoCompactDraft();
4079
+ leaveContextTab();
4080
+ });
4081
+ afterContextRender();
4053
4082
  }
4054
4083
 
4055
4084
  async function showUsage() {
@@ -4111,6 +4140,9 @@ async function showContext() {
4111
4140
  });
4112
4141
  return;
4113
4142
  }
4143
+ // A fresh open shows Context first; Media is one tap away.
4144
+ initContextTabs();
4145
+ resetContextTab(convId);
4114
4146
  if (btn) {
4115
4147
  btn.textContent = '⏳';
4116
4148
  btn.disabled = true;
@@ -5141,6 +5173,13 @@ function toggleChatActionsMenu() {
5141
5173
  syncThemeMenuLabel();
5142
5174
  syncAboutMenuLabel();
5143
5175
  const willOpen = !!menu.hidden;
5176
+ // An image chat's cost, asked from the relay each time the menu opens.
5177
+ if (willOpen) {
5178
+ void syncChatMenuImageCost({
5179
+ conversationId: currentConvId,
5180
+ imageChat: isOpenAIImageModelId(document.getElementById('model-select')?.value || ''),
5181
+ }).then(() => { if (!menu.hidden) placeChatActionsMenu(); });
5182
+ }
5144
5183
  menu.hidden = !willOpen;
5145
5184
  trigger.setAttribute('aria-expanded', willOpen ? 'true' : 'false');
5146
5185
  if (willOpen) {
@@ -5717,6 +5756,14 @@ async function initApp() {
5717
5756
  closeChatActionsMenu();
5718
5757
  openCurrentCloudSession(showExternalLinkFallback);
5719
5758
  });
5759
+ // The global Media view: every session's pictures, in the transcript's place.
5760
+ bindMenuAction(document.getElementById('chat-menu-media'), (event) => {
5761
+ event.preventDefault();
5762
+ event.stopPropagation();
5763
+ lockChatActionsMenuShield(350);
5764
+ closeChatActionsMenu();
5765
+ void window.toggleMediaView();
5766
+ });
5720
5767
  const chatMenuSettingsBtn = document.getElementById('chat-menu-settings');
5721
5768
  bindMenuAction(chatMenuSettingsBtn, (event) => {
5722
5769
  event.preventDefault();
@@ -5990,6 +6037,7 @@ async function bootstrap() {
5990
6037
  // Leaving the Inbox goes back to the conversation it was opened from.
5991
6038
  // Opened from Status it stored none: `returnTo` is the session history stands on.
5992
6039
  closeInboxView: ({ returnTo = '' } = {}) => { if (isInboxViewActive()) void toggleInboxView({ returnTo }); },
6040
+ closeMediaView: ({ returnTo = '' } = {}) => { if (isMediaViewActive()) void toggleMediaView({ returnTo }); },
5993
6041
  closeChangeCwdMenu: () => closeChangeCwdMenu({ focusTrigger: true }),
5994
6042
  closeGitChangesModal,
5995
6043
  closeGitDiffViewer,
@@ -6169,9 +6217,21 @@ window.openOriginConversation = openOriginConversation;
6169
6217
  // history stands on.
6170
6218
  window.toggleStatusView = () => {
6171
6219
  leaveInboxView();
6220
+ leaveMediaView();
6172
6221
  return toggleStatusView({ returnTo: sessionHistoryConversation() });
6173
6222
  };
6174
- window.toggleInboxView = () => toggleInboxView({ returnTo: sessionHistoryConversation() });
6223
+ window.toggleInboxView = () => {
6224
+ leaveMediaView();
6225
+ return toggleInboxView({ returnTo: sessionHistoryConversation() });
6226
+ };
6227
+ // The global Media view shares that place too.
6228
+ window.toggleMediaView = () => toggleMediaView({
6229
+ returnTo: sessionHistoryConversation(),
6230
+ leaveOthers: () => {
6231
+ leaveInboxView();
6232
+ leaveStatusView();
6233
+ },
6234
+ });
6175
6235
  window.refreshConversations = refreshConversations;
6176
6236
  window.renderConvList = renderConvList;
6177
6237
  window.handleAttachmentInput = handleAttachmentInput;
@@ -67,6 +67,7 @@ export const CHAT_ACTIONS_MENU_ENTRY_IDS = Object.freeze([
67
67
  'chat-menu-change-cwd',
68
68
  'chat-menu-git-changes',
69
69
  'chat-menu-open-cloud-session',
70
+ 'chat-menu-media',
70
71
  'chat-menu-settings',
71
72
  'chat-menu-about',
72
73
  'chat-menu-restart-relay',