@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,233 @@
1
+ // The placeholder of a running OpenAI image turn, inside the live bubble
2
+ // (#thinking-indicator): an elapsed timer, the size, quality, count and
3
+ // model, and the newest partial of each image as the relay streams them
4
+ // (image_started, image_preview, image_preview_reset; owner pages only).
5
+ //
6
+ // It lives in the live bubble, so it goes with it: never stored, never in a
7
+ // share view (the relay sends shares no such event, and IS_SHARED_VIEW
8
+ // ignores them all the same), and never in the media gallery (which reads
9
+ // `.msg-bubble img`). The image area is sized by the size's aspect ratio from
10
+ // the start, so a partial arriving does not move the transcript.
11
+
12
+ import { BASE, IS_SHARED_VIEW, currentConvId } from './store.js';
13
+ import {
14
+ formatImageElapsed,
15
+ imageAspectRatio,
16
+ imagePlaceholderDetails,
17
+ reduceImageTurnState,
18
+ } from './openai-image-composer.mjs';
19
+
20
+ const LIVE_BUBBLE_ID = 'thinking-indicator';
21
+ const TICK_MS = 1000;
22
+
23
+ // The open conversation's image turn, or null.
24
+ let turn = null;
25
+ let timer = null;
26
+
27
+ function openConversationId() {
28
+ return String(currentConvId || '').trim();
29
+ }
30
+
31
+ function liveBubbleFor(state) {
32
+ if (typeof document === 'undefined' || !state) return null;
33
+ const bubble = document.getElementById(LIVE_BUBBLE_ID);
34
+ if (!bubble) return null;
35
+ if (String(bubble.dataset.conversationId || '') !== state.conversationId) return null;
36
+ const bubbleMessageId = String(bubble.dataset.messageId || '').trim();
37
+ if (bubbleMessageId && bubbleMessageId !== state.messageId) return null;
38
+ return bubble;
39
+ }
40
+
41
+ function stopTimer() {
42
+ if (timer) clearInterval(timer);
43
+ timer = null;
44
+ }
45
+
46
+ function ensureTimer() {
47
+ if (timer || typeof setInterval !== 'function') return;
48
+ timer = setInterval(tick, TICK_MS);
49
+ timer?.unref?.();
50
+ }
51
+
52
+ function tick() {
53
+ const node = typeof document === 'undefined' ? null : document.querySelector('[data-image-placeholder]');
54
+ if (!node || !turn) {
55
+ stopTimer();
56
+ return;
57
+ }
58
+ const clock = node.querySelector('[data-image-placeholder-timer]');
59
+ const text = formatImageElapsed(Date.now() - (turn.startedAtMs ?? Date.now()));
60
+ if (clock && clock.textContent !== text) clock.textContent = text;
61
+ }
62
+
63
+ function removePlaceholderNodes() {
64
+ if (typeof document === 'undefined') return;
65
+ for (const node of document.querySelectorAll('[data-image-placeholder]')) node.remove();
66
+ }
67
+
68
+ function buildPlaceholder(state) {
69
+ const root = document.createElement('div');
70
+ root.className = 'image-turn-placeholder';
71
+ root.setAttribute('data-image-placeholder', '');
72
+ root.setAttribute('role', 'status');
73
+ root.innerHTML = `
74
+ <div class="image-turn-placeholder-meta">
75
+ <span class="image-turn-placeholder-label">Making the image</span>
76
+ <span class="image-turn-placeholder-timer" data-image-placeholder-timer>0:00</span>
77
+ <span class="image-turn-placeholder-retry" data-image-placeholder-retry hidden>trying again</span>
78
+ </div>
79
+ <div class="image-turn-placeholder-details" data-image-placeholder-details></div>
80
+ <div class="image-turn-placeholder-grid" data-image-placeholder-grid></div>`;
81
+ root.dataset.messageId = state.messageId;
82
+ return root;
83
+ }
84
+
85
+ function syncGrid(grid, state) {
86
+ const count = Math.max(1, Number(state.count) || 1);
87
+ grid.dataset.count = String(count);
88
+ const ratio = imageAspectRatio(state.size);
89
+ while (grid.children.length > count) grid.lastElementChild.remove();
90
+ for (let index = 0; index < count; index += 1) {
91
+ let tile = grid.children[index];
92
+ if (!tile) {
93
+ tile = document.createElement('div');
94
+ tile.className = 'image-turn-placeholder-tile';
95
+ tile.dataset.imageIndex = String(index);
96
+ grid.appendChild(tile);
97
+ }
98
+ if (tile.style.aspectRatio !== ratio) tile.style.aspectRatio = ratio;
99
+ const partial = state.partials?.[index] || null;
100
+ let img = tile.querySelector('img[data-image-placeholder-preview]');
101
+ if (!partial) {
102
+ img?.remove();
103
+ tile.classList.remove('has-preview');
104
+ continue;
105
+ }
106
+ if (!img) {
107
+ img = document.createElement('img');
108
+ img.setAttribute('data-image-placeholder-preview', '');
109
+ img.dataset.imageIndex = String(index);
110
+ img.alt = count > 1 ? `Preview of image ${index + 1}` : 'Preview of the image';
111
+ img.decoding = 'async';
112
+ tile.appendChild(img);
113
+ }
114
+ if (img.getAttribute('src') !== partial.url) img.setAttribute('src', partial.url);
115
+ img.dataset.partialIndex = String(partial.partialIndex);
116
+ tile.classList.add('has-preview');
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Draws the open conversation's image turn into its live bubble, or takes
122
+ * the placeholder away when there is none (or the bubble is another turn's).
123
+ * Called after each event and whenever the live bubble is (re)built.
124
+ */
125
+ export function renderImageTurnPlaceholder() {
126
+ if (typeof document === 'undefined') return;
127
+ const state = turn && turn.conversationId === openConversationId() ? turn : null;
128
+ const bubble = liveBubbleFor(state);
129
+ if (!state || !bubble) {
130
+ removePlaceholderNodes();
131
+ if (!state) stopTimer();
132
+ return;
133
+ }
134
+ const host = bubble.querySelector('.thinking-bubble') || bubble;
135
+ let node = host.querySelector('[data-image-placeholder]');
136
+ if (node && node.dataset.messageId !== state.messageId) {
137
+ node.remove();
138
+ node = null;
139
+ }
140
+ if (!node) {
141
+ node = buildPlaceholder(state);
142
+ const header = host.querySelector('.thinking-bubble-header');
143
+ if (header) header.after(node);
144
+ else host.prepend(node);
145
+ }
146
+ node.dataset.attempt = String(state.attempt);
147
+ const details = node.querySelector('[data-image-placeholder-details]');
148
+ const detailText = imagePlaceholderDetails(state);
149
+ if (details && details.textContent !== detailText) details.textContent = detailText;
150
+ const retry = node.querySelector('[data-image-placeholder-retry]');
151
+ if (retry) retry.hidden = !state.retrying;
152
+ const grid = node.querySelector('[data-image-placeholder-grid]');
153
+ if (grid) syncGrid(grid, state);
154
+ tick();
155
+ ensureTimer();
156
+ }
157
+
158
+ function accept(payload) {
159
+ if (IS_SHARED_VIEW || !payload || typeof payload !== 'object') return false;
160
+ const conversationId = String(payload.conversationId || '').trim();
161
+ return !!conversationId && conversationId === openConversationId();
162
+ }
163
+
164
+ function apply(type, payload) {
165
+ if (!accept(payload)) return;
166
+ // Another turn of the open conversation replaces the one held.
167
+ const held = turn && turn.conversationId === payload.conversationId ? turn : null;
168
+ turn = reduceImageTurnState(held, type, payload, { base: BASE });
169
+ renderImageTurnPlaceholder();
170
+ }
171
+
172
+ /** `image_started` { conversationId, messageId, attempt, size, quality, count, model, startedAt }. */
173
+ export function handleImageStarted(payload) {
174
+ apply('started', payload);
175
+ }
176
+
177
+ /** `image_preview` { conversationId, messageId, attempt, imageIndex, partialIndex, contentUrl, … }. */
178
+ export function handleImagePreview(payload) {
179
+ apply('preview', payload);
180
+ }
181
+
182
+ /** `image_preview_reset` { conversationId, messageId, attempt, reason }. */
183
+ export function handleImagePreviewReset(payload) {
184
+ if (!accept(payload)) return;
185
+ if (!turn || turn.messageId !== String(payload.messageId || '').trim()) return;
186
+ apply('reset', payload);
187
+ }
188
+
189
+ /**
190
+ * A running image turn the page learns of from the conversation's in-flight
191
+ * snapshot (opened or reconnected mid-turn): the timer runs from the turn's
192
+ * own start. What the relay sent live for the same turn is kept; `image`
193
+ * (optional) carries the turn's size, quality, count, model, attempt and
194
+ * startedAt, and `previews` its newest partials, when the snapshot has them.
195
+ */
196
+ export function noteInFlightImageTurn({ conversationId, messageId, startedAt, model = '', image = null } = {}) {
197
+ if (IS_SHARED_VIEW) return;
198
+ const convId = String(conversationId || '').trim();
199
+ const msgId = String(messageId || '').trim();
200
+ if (!convId || !msgId || convId !== openConversationId()) return;
201
+ const held = turn && turn.conversationId === convId && turn.messageId === msgId ? turn : null;
202
+ const info = image && typeof image === 'object' ? image : {};
203
+ // What the live events said wins over the snapshot.
204
+ let next = held || reduceImageTurnState(null, 'started', {
205
+ conversationId: convId,
206
+ messageId: msgId,
207
+ attempt: info.attempt,
208
+ size: info.size,
209
+ quality: info.quality,
210
+ count: info.count,
211
+ model: info.model || model,
212
+ startedAt: info.startedAt || startedAt,
213
+ }, { base: BASE });
214
+ for (const preview of Array.isArray(info.previews) ? info.previews : []) {
215
+ next = reduceImageTurnState(next, 'preview', { ...preview, conversationId: convId, messageId: msgId }, { base: BASE });
216
+ }
217
+ turn = next;
218
+ renderImageTurnPlaceholder();
219
+ }
220
+
221
+ /** The turn ended or the page left it: the placeholder goes. */
222
+ export function clearImageTurnPlaceholder(messageId = '') {
223
+ const id = String(messageId || '').trim();
224
+ if (id && turn && turn.messageId !== id) return;
225
+ turn = null;
226
+ removePlaceholderNodes();
227
+ stopTimer();
228
+ }
229
+
230
+ /** For tests: the held turn state. */
231
+ export function imageTurnPlaceholderState() {
232
+ return turn;
233
+ }
@@ -126,12 +126,14 @@ import {
126
126
  } from './new-conversation-model-choice.mjs';
127
127
  import { buildCatalogModelOptions } from './model-selector-options.mjs';
128
128
  import { isReasoningOffUnsupported, reasoningEffortOptionLabel, reasoningEffortOptionTitle } from './reasoning-effort-labels.mjs';
129
+ import { rememberImageComposerValues } from './image-composer-ui.js';
129
130
  import {
130
131
  conversationProviderIndicatorKey,
131
132
  conversationProviderIndicatorLabel,
132
133
  } from './conversation-provider-indicator.mjs';
133
134
  import { leaveStatusView } from './status-view.mjs';
134
135
  import { leaveInboxView } from './inbox-ui.js';
136
+ import { leaveMediaView } from './media-view.js';
135
137
  import {
136
138
  CLAUDE_CLOUD_PROVIDER,
137
139
  buildCloudSourceWarnings,
@@ -1330,7 +1332,8 @@ function showConversationUnknown(id, { lastOpenedBefore = '' } = {}) {
1330
1332
 
1331
1333
  export async function openConversation(id, options = {}) {
1332
1334
  // The Inbox owns the transcript like the status view does.
1333
- const didLeaveInboxView = leaveInboxView();
1335
+ const didLeaveMediaView = leaveMediaView();
1336
+ const didLeaveInboxView = leaveInboxView() || didLeaveMediaView;
1334
1337
  const didLeaveStatusView = leaveStatusView() || didLeaveInboxView;
1335
1338
  document.getElementById('input-area')?.removeAttribute('hidden');
1336
1339
  const previousConversationId = String(currentConvId || '').trim();
@@ -1844,9 +1847,14 @@ async function populateNewConversationModelSelect(providerType = 'github') {
1844
1847
  const cloudDefault = claudeCloudDefaultModel(newConversationClaudeCloudSettingsCache, eligibleModels);
1845
1848
  if (cloudDefault) target.value = cloudDefault;
1846
1849
  } else {
1850
+ // An OpenAI Image chat starts on the relay's image default (its OpenAI
1851
+ // settings name it) unless the last model used is an image one it offers.
1852
+ const providerDefault = normalizedProvider === 'claude-api'
1853
+ ? newConversationClaudeApiSettingsCache?.model
1854
+ : (normalizedProvider === 'openai-image' ? newConversationOpenAISettingsCache?.imageDefaultModel : '');
1847
1855
  const preselected = preselectNewConversationModel(Array.from(target.options, (option) => option.value), {
1848
1856
  storedModel,
1849
- providerDefault: normalizedProvider === 'claude-api' ? newConversationClaudeApiSettingsCache?.model : '',
1857
+ providerDefault,
1850
1858
  allowAuto: normalizedProvider !== 'openai-image',
1851
1859
  });
1852
1860
  if (preselected) target.value = preselected;
@@ -2561,6 +2569,8 @@ async function openBootstrappedConversation({
2561
2569
  // default model and has no effort, so it must not become what the next
2562
2570
  // local chat starts with.
2563
2571
  if (selectedProvider !== CLAUDE_CLOUD_PROVIDER) rememberBootstrappedSelection(payload);
2572
+ // The chosen size becomes the new chat's own, read when its composer is built.
2573
+ if (selectedProvider === 'openai-image' && selectedSize) rememberImageComposerValues(conversationId, { size: selectedSize });
2564
2574
  await refreshConversations();
2565
2575
  await openConversation(conversationId);
2566
2576
  if (selectedProvider === 'openai-image') {
@@ -0,0 +1,202 @@
1
+ // The media gallery's logic without the DOM: the query a view asks
2
+ // GET /api/media with, month sections, how a gallery item becomes an item of
3
+ // the file viewer, what the viewer says about it, and which buttons it gets.
4
+
5
+ export const MEDIA_TEXT = Object.freeze({
6
+ title: 'Media',
7
+ tabMedia: 'Media',
8
+ tabFiles: 'Files',
9
+ tabContext: 'Context',
10
+ sourceAll: 'All',
11
+ sourceMine: 'Mine',
12
+ sourceAgent: 'Agent',
13
+ sourceGenerated: 'Generated',
14
+ allProviders: 'All providers',
15
+ allSessions: 'All sessions',
16
+ hideArchived: 'Hide archived',
17
+ search: 'Search name, alt text, session',
18
+ emptyMedia: 'No pictures or videos yet.',
19
+ emptyFiles: 'No files yet.',
20
+ emptyFiltered: 'Nothing matches these filters.',
21
+ loading: 'Loading…',
22
+ loadMore: 'Load more',
23
+ failed: 'Could not load the media.',
24
+ indexing: (count) => `Indexing older messages… (${count >= 10001 ? '10,000+' : count} left)`,
25
+ missing: (count) => `${count} missing ${count === 1 ? 'file is' : 'files are'} not shown.`,
26
+ sourceLabel: 'Source',
27
+ providerLabel: 'Provider',
28
+ sessionLabel: 'Session',
29
+ untitled: 'picture',
30
+ copied: 'Copied.',
31
+ copyFailed: 'Could not copy.',
32
+ openInSession: 'Open in session',
33
+ copyPath: 'Copy path',
34
+ copyLink: 'Copy link',
35
+ fromYou: '👤 You',
36
+ fromAgent: '🤖 Agent',
37
+ generated: '✨ Generated',
38
+ remote: '🌐',
39
+ archived: 'archived',
40
+ inMessages: (count) => `in ${count} messages`,
41
+ });
42
+
43
+ export const MEDIA_PAGE_SIZE = 60;
44
+
45
+ /** The query string of one page of a gallery view. */
46
+ export function mediaQuery({
47
+ category = 'media',
48
+ conversationId = '',
49
+ source = 'all',
50
+ provider = '',
51
+ hideArchived = false,
52
+ q = '',
53
+ cursor = '',
54
+ limit = MEDIA_PAGE_SIZE,
55
+ } = {}) {
56
+ const params = new URLSearchParams();
57
+ params.set('category', category === 'file' ? 'file' : 'media');
58
+ if (conversationId) params.set('conversationId', conversationId);
59
+ if (source && source !== 'all') params.set('source', source);
60
+ if (provider) params.set('provider', provider);
61
+ if (hideArchived) params.set('archived', 'hide');
62
+ const text = String(q || '').trim();
63
+ if (text) params.set('q', text);
64
+ if (cursor) params.set('cursor', cursor);
65
+ params.set('limit', String(limit));
66
+ return params.toString();
67
+ }
68
+
69
+ /** Items grouped by the month of their message, newest first as they come: `[{ key, label, items }]`. */
70
+ export function groupByMonth(items, { locale, timeZone } = {}) {
71
+ const sections = [];
72
+ for (const item of Array.isArray(items) ? items : []) {
73
+ const date = new Date(item?.timestamp);
74
+ const valid = !Number.isNaN(date.getTime());
75
+ const parts = valid
76
+ ? new Intl.DateTimeFormat('en-CA', { year: 'numeric', month: '2-digit', timeZone }).formatToParts(date)
77
+ : [];
78
+ const year = parts.find((part) => part.type === 'year')?.value || '';
79
+ const month = parts.find((part) => part.type === 'month')?.value || '';
80
+ const key = valid ? `${year}-${month}` : 'unknown';
81
+ let section = sections[sections.length - 1];
82
+ if (!section || section.key !== key) {
83
+ const label = valid ? date.toLocaleDateString(locale, { year: 'numeric', month: 'long', timeZone }) : '';
84
+ section = { key, label, items: [] };
85
+ sections.push(section);
86
+ }
87
+ section.items.push(item);
88
+ }
89
+ return sections;
90
+ }
91
+
92
+ export function formatMediaSize(bytes) {
93
+ const value = Number(bytes);
94
+ if (!Number.isFinite(value) || value <= 0) return '';
95
+ if (value < 1024) return `${value} B`;
96
+ if (value < 1024 * 1024) return `${(value / 1024).toFixed(value < 10 * 1024 ? 1 : 0)} KB`;
97
+ return `${(value / (1024 * 1024)).toFixed(1)} MB`;
98
+ }
99
+
100
+ /** The icon of a row of the Files tab. */
101
+ export function mediaFileIcon(item) {
102
+ if (item?.mediaKind === 'pdf') return '📄';
103
+ if (item?.mediaKind === 'audio') return '🎵';
104
+ return '📎';
105
+ }
106
+
107
+ /** The badge of an item: who it came from. */
108
+ export function mediaSourceBadge(item) {
109
+ if (item?.origin === 'generated') return MEDIA_TEXT.generated;
110
+ return item?.sender === 'me' ? MEDIA_TEXT.fromYou : MEDIA_TEXT.fromAgent;
111
+ }
112
+
113
+ /** The address the viewer loads an item from (the relay's route, or the remote picture itself). */
114
+ export function mediaHref(item, base = '') {
115
+ if (item?.contentUrl) return `${base}${item.contentUrl}`;
116
+ // Only a web address is loaded from elsewhere (the server sends no other kind).
117
+ const remote = String(item?.remoteUrl || '');
118
+ return /^https?:\/\//i.test(remote) ? remote : '';
119
+ }
120
+
121
+ /** A file viewer gallery item for a media item. */
122
+ export function mediaViewerItem(item, base = '') {
123
+ return {
124
+ key: `media:${item.id}`,
125
+ source: 'upload',
126
+ href: mediaHref(item, base),
127
+ name: String(item?.name || item?.alt || MEDIA_TEXT.untitled),
128
+ mime: String(item?.mime || (item?.mediaKind === 'video' ? 'video/mp4' : item?.mediaKind === 'image' ? 'image/png' : '')),
129
+ size: Number.isFinite(item?.sizeBytes) ? item.sizeBytes : 0,
130
+ media: item,
131
+ };
132
+ }
133
+
134
+ export function formatMediaDate(timestamp, locale = undefined) {
135
+ const date = new Date(timestamp);
136
+ if (Number.isNaN(date.getTime())) return '';
137
+ return date.toLocaleString(locale, { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
138
+ }
139
+
140
+ /** The caption line: session title · date · who · how often · archived. */
141
+ export function mediaCaption(item, { locale } = {}) {
142
+ if (!item) return '';
143
+ const parts = [
144
+ String(item.conversationTitle || '').trim(),
145
+ formatMediaDate(item.timestamp, locale),
146
+ mediaSourceBadge(item) + (item.remoteUrl ? ` ${MEDIA_TEXT.remote}` : ''),
147
+ ];
148
+ if (Number(item.occurrences) > 1) parts.push(MEDIA_TEXT.inMessages(Number(item.occurrences)));
149
+ if (item.archived) parts.push(MEDIA_TEXT.archived);
150
+ return parts.filter(Boolean).join(' · ');
151
+ }
152
+
153
+ /** What "Copy path / Copy link" copies, or '' when there is nothing useful (uploads, data images). */
154
+ export function mediaCopyTarget(item) {
155
+ if (item?.path) return { label: MEDIA_TEXT.copyPath, text: String(item.path) };
156
+ if (item?.remoteUrl) return { label: MEDIA_TEXT.copyLink, text: String(item.remoteUrl) };
157
+ return null;
158
+ }
159
+
160
+ /**
161
+ * The viewer gallery for a list of media items, open at `index`. `loadMore`
162
+ * resolves the next page of media items (or null); `actions` carries what
163
+ * the page does for "Open in session" and copying.
164
+ */
165
+ export function mediaViewerGallery(items, index, {
166
+ base = '',
167
+ hasMore = false,
168
+ loadMore = null,
169
+ openInSession = null,
170
+ copyText = null,
171
+ locale,
172
+ } = {}) {
173
+ const viewerItems = items.map((item) => mediaViewerItem(item, base));
174
+ return {
175
+ source: 'media',
176
+ items: viewerItems,
177
+ index,
178
+ hasMore: Boolean(hasMore && loadMore),
179
+ loadMore: loadMore
180
+ ? async () => {
181
+ const page = await loadMore();
182
+ return {
183
+ items: (page?.items || []).map((item) => mediaViewerItem(item, base)),
184
+ hasMore: Boolean(page?.hasMore),
185
+ };
186
+ }
187
+ : null,
188
+ describe(viewerItem) {
189
+ const item = viewerItem?.media;
190
+ if (!item) return null;
191
+ const actions = [];
192
+ if (typeof openInSession === 'function' && item.conversationId && item.messageId) {
193
+ actions.push({ id: 'open-in-session', label: MEDIA_TEXT.openInSession, run: () => openInSession(item) });
194
+ }
195
+ const copy = mediaCopyTarget(item);
196
+ if (copy && typeof copyText === 'function') {
197
+ actions.push({ id: 'copy-location', label: copy.label, run: () => copyText(copy.text) });
198
+ }
199
+ return { caption: mediaCaption(item, { locale }), actions };
200
+ },
201
+ };
202
+ }