@oar-sh/oar 0.9.7 → 0.9.9

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 (64) hide show
  1. package/CHANGELOG.md +344 -0
  2. package/README.md +89 -40
  3. package/bin/oar.js +1023 -135
  4. package/package.json +4 -5
  5. package/server/README.md +20 -1
  6. package/server/claude-worker/claude-sdk-adapter.mjs +0 -11
  7. package/server/claude-worker/claude-session-process.mjs +196 -28
  8. package/server/claude-worker/sdk-message-normalizer.mjs +28 -0
  9. package/server/db-schema.mjs +8 -0
  10. package/server/migrations/0008-message-pins.mjs +85 -0
  11. package/server/public/app/activity-replay-state.mjs +68 -5
  12. package/server/public/app/api-client.js +36 -2
  13. package/server/public/app/attachments-view.js +508 -125
  14. package/server/public/app/back-guard.mjs +58 -0
  15. package/server/public/app/bootstrap.js +170 -59
  16. package/server/public/app/context-usage-view.mjs +127 -8
  17. package/server/public/app/conversation-view.js +172 -32
  18. package/server/public/app/external-link-policy.mjs +4 -1
  19. package/server/public/app/file-version.mjs +58 -0
  20. package/server/public/app/journal-view.js +16 -2
  21. package/server/public/app/pinned-messages-view.mjs +253 -0
  22. package/server/public/app/pwa-install.js +6 -1
  23. package/server/public/app/relay-dot-state.mjs +8 -1
  24. package/server/public/app/router.js +8 -2
  25. package/server/public/app/scrollbar-hover.mjs +78 -0
  26. package/server/public/app/settings-modal.js +4 -1
  27. package/server/public/app/socket-handlers.js +8 -1
  28. package/server/public/app/store.js +32 -0
  29. package/server/public/app/transcript-separators.mjs +48 -11
  30. package/server/public/app/viewer-chat-gallery.mjs +59 -0
  31. package/server/public/app/viewer-controls-state.mjs +59 -0
  32. package/server/public/app/viewer-gallery.mjs +46 -0
  33. package/server/public/app/viewer-gestures.mjs +105 -0
  34. package/server/public/app/viewer-history.mjs +43 -0
  35. package/server/public/app/viewer-tap.mjs +112 -0
  36. package/server/public/app-icon-192.png +0 -0
  37. package/server/public/app-icon-512.png +0 -0
  38. package/server/public/app-icon-light-192.png +0 -0
  39. package/server/public/app-icon-light-512.png +0 -0
  40. package/server/public/app-icon-light-maskable-512.png +0 -0
  41. package/server/public/app-icon-light.svg +4 -0
  42. package/server/public/app-icon-maskable-512.png +0 -0
  43. package/server/public/app-icon.svg +3 -3
  44. package/server/public/favicon.ico +0 -0
  45. package/server/public/index.html +487 -67
  46. package/server/public/manifest.webmanifest +9 -3
  47. package/server/public/sw.js +6 -1
  48. package/server/repositories/message-pin-statements.mjs +39 -0
  49. package/server/repositories/message-repository.mjs +5 -0
  50. package/server/routes/messages-routes.mjs +36 -62
  51. package/server/routes/sessions-routes.mjs +107 -26
  52. package/server/server-runtime.mjs +39 -8
  53. package/server/services/claude-context-usage.mjs +51 -3
  54. package/server/services/cloudflared-tunnel-service.mjs +139 -41
  55. package/server/services/file-serving.mjs +155 -0
  56. package/server/services/message-pin-list.mjs +52 -0
  57. package/server/services/oar-cli-helpers.mjs +205 -4
  58. package/server/services/oar-cli-menu.mjs +240 -0
  59. package/server/services/oar-cli-wsl.mjs +129 -0
  60. package/server/services/pwa-manifest-theme.mjs +30 -0
  61. package/server/services/safe-served-content.mjs +61 -11
  62. package/server/services/session-history-refresh-service.mjs +24 -0
  63. package/server/services/session-worker-registry-service.mjs +18 -1
  64. package/server/services/update-install-service.mjs +9 -3
@@ -49,9 +49,21 @@ import {
49
49
  copyReferenceTokenToClipboard,
50
50
  eventClosest,
51
51
  } from './router.js';
52
+ import { buildDownloadLink } from './file-version.mjs';
52
53
  import { markdownHeadingId, resolveFilePreviewLink } from './file-preview-navigation.mjs';
53
54
  import { openExternalNavigation } from './external-link-policy.mjs';
54
55
  import { attachCodeCopyButtons } from './code-copy.mjs';
56
+ import { createControlsState } from './viewer-controls-state.mjs';
57
+ import { createTapRecognizer } from './viewer-tap.mjs';
58
+ import { createSwipeRecognizer } from './viewer-gestures.mjs';
59
+ import { createViewerHistory } from './viewer-history.mjs';
60
+ import {
61
+ galleryAt,
62
+ galleryFromFolder,
63
+ galleryNeighborIndex,
64
+ galleryNeighbors,
65
+ galleryPositionLabel,
66
+ } from './viewer-gallery.mjs';
55
67
  import {
56
68
  deepestExistingAncestor,
57
69
  planRepoRehydration,
@@ -76,6 +88,15 @@ function isVideoMimeType(mimeType) {
76
88
  return String(mimeType || '').toLowerCase().startsWith('video/');
77
89
  }
78
90
 
91
+ function isAudioMimeType(mimeType) {
92
+ return String(mimeType || '').toLowerCase().startsWith('audio/');
93
+ }
94
+
95
+ // Kinds whose bytes arrive as text in the preview payload and read in a
96
+ // column; the others are shown edge to edge on black, or as a card.
97
+ const TEXT_PREVIEW_KINDS = new Set(['markdown', 'code', 'text']);
98
+ const DARK_PREVIEW_KINDS = new Set(['image', 'video', 'audio']);
99
+
79
100
  function normalizeVideoPreviewOptions(options = {}) {
80
101
  const startSeconds = Math.max(0, Number(options?.startSeconds ?? options?.startAtSeconds ?? 0) || 0);
81
102
  const preload = String(options?.preload || 'metadata').toLowerCase();
@@ -195,17 +216,18 @@ export function renderAttachmentMarkup(attachments, { messageId = '' } = {}) {
195
216
  : ''}
196
217
  </div>`
197
218
  : '';
198
- if ((isImage || isVideo) && rawUrl) {
199
- const jsName = escHtml(JSON.stringify(att?.name || 'attachment'));
200
- const jsUrl = escHtml(JSON.stringify(rawUrl));
201
- const jsType = escHtml(JSON.stringify(att?.type || 'image/jpeg'));
202
- const openHandler = `openUploadedAttachmentViewer(${jsName},${jsUrl},${jsType})`;
219
+ const isAudio = isAudioMimeType(att?.type);
220
+ if ((isImage || isVideo || isAudio) && rawUrl) {
221
+ // Opened by the conversation's click handler (see
222
+ // viewer-chat-gallery.mjs), which also finds the other media around it.
223
+ const mediaKind = isVideo ? 'video' : (isAudio ? 'audio' : 'image');
224
+ const chip = isVideo ? '🎞️' : '🎵';
203
225
  return `
204
- <div class="msg-attachment msg-attachment-${isVideo ? 'video' : 'image'}">
205
- ${isVideo
206
- ? `<div class="msg-attachment-video-chip">🎞️</div>`
207
- : `<img src="${escHtml(rawUrl)}" alt="${name}" loading="eager" decoding="async" onclick="${openHandler}">`}
208
- <div class="msg-attachment-meta"><a href="#" onclick="${openHandler};return false;">${name}</a> · ${type}${sizeText} · <a href="#" onclick="${openHandler};return false;">open</a>${continuityActions}</div>
226
+ <div class="msg-attachment msg-attachment-${mediaKind}" data-media-name="${name}" data-media-url="${escHtml(rawUrl)}" data-media-type="${escHtml(att?.type || 'image/jpeg')}">
227
+ ${isImage
228
+ ? `<img src="${escHtml(rawUrl)}" alt="${name}" loading="eager" decoding="async">`
229
+ : `<div class="msg-attachment-video-chip">${chip}</div>`}
230
+ <div class="msg-attachment-meta"><a href="#" data-media-open>${name}</a> · ${type}${sizeText} · <a href="#" data-media-open>open</a>${continuityActions}</div>
209
231
  </div>`;
210
232
  }
211
233
  if (rawUrl) {
@@ -610,8 +632,10 @@ function updateFilePreviewUiState() {
610
632
  const payload = filePreviewState.payload;
611
633
  const isMarkdown = payload?.kind === 'markdown';
612
634
  const isUpload = filePreviewState.source === 'upload';
613
- previewBtn.style.display = isUpload ? 'none' : '';
614
- rawBtn.style.display = isUpload ? 'none' : '';
635
+ // Preview/Raw only mean something for text; media has no raw form to show.
636
+ const hasTextModes = !isUpload && TEXT_PREVIEW_KINDS.has(payload?.kind);
637
+ previewBtn.style.display = hasTextModes ? '' : 'none';
638
+ rawBtn.style.display = hasTextModes ? '' : 'none';
615
639
  previewBtn.classList.toggle('active', filePreviewState.mode === 'preview');
616
640
  rawBtn.classList.toggle('active', filePreviewState.mode === 'raw');
617
641
  htmlBtn.style.display = (!isUpload && isMarkdown && filePreviewState.mode === 'preview') ? 'inline-block' : 'none';
@@ -625,6 +649,14 @@ function updateFilePreviewUiState() {
625
649
  if (annotateBtn) annotateBtn.hidden = !filePreviewSupportsAnnotation();
626
650
  const copyBtn = document.getElementById('file-preview-copy-btn');
627
651
  if (copyBtn) copyBtn.hidden = payload?.kind !== 'image';
652
+ const gallery = filePreviewState.gallery;
653
+ const bottomChrome = document.getElementById('file-preview-chrome-bottom');
654
+ if (bottomChrome) {
655
+ bottomChrome.hidden = !gallery;
656
+ document.getElementById('file-preview-position').textContent = galleryPositionLabel(gallery);
657
+ document.getElementById('file-preview-prev').disabled = galleryNeighborIndex(gallery, -1) === -1;
658
+ document.getElementById('file-preview-next').disabled = galleryNeighborIndex(gallery, 1) === -1;
659
+ }
628
660
  }
629
661
 
630
662
  /**
@@ -681,6 +713,7 @@ function snapshotFilePreviewState() {
681
713
  error: filePreviewState.error,
682
714
  payload: filePreviewState.payload,
683
715
  viewerOptions: filePreviewState.viewerOptions,
716
+ gallery: filePreviewState.gallery,
684
717
  scrollTop: Number(bodyEl?.scrollTop || 0),
685
718
  };
686
719
  }
@@ -728,25 +761,92 @@ function teardownImageZoom() {
728
761
  if (bodyEl) bodyEl.classList.remove('image-zoom-mode');
729
762
  }
730
763
 
731
- function teardownVideoPreview() {
732
- const video = videoPreview.videoEl;
733
- const onLoadedMetadata = videoPreview.onLoadedMetadata;
734
- const onError = videoPreview.onError;
735
- const onCanPlay = videoPreview.onCanPlay;
736
- if (video) {
737
- if (onLoadedMetadata) video.removeEventListener('loadedmetadata', onLoadedMetadata);
738
- if (onError) video.removeEventListener('error', onError);
739
- if (onCanPlay) video.removeEventListener('canplay', onCanPlay);
764
+ function teardownMediaPreview() {
765
+ const media = mediaPreview.el;
766
+ if (media) {
767
+ for (const [eventName, handler] of mediaPreview.listeners) media.removeEventListener(eventName, handler);
740
768
  try {
741
- video.pause();
769
+ media.pause();
742
770
  } catch {}
743
771
  }
744
- videoPreview.videoEl = null;
745
- videoPreview.onLoadedMetadata = null;
746
- videoPreview.onError = null;
747
- videoPreview.onCanPlay = null;
772
+ mediaPreview.el = null;
773
+ mediaPreview.listeners = [];
774
+ viewerControls.setPlaying(false);
748
775
  const bodyEl = document.getElementById('file-preview-body');
749
- if (bodyEl) bodyEl.classList.remove('video-preview-mode');
776
+ if (bodyEl) bodyEl.classList.remove('video-preview-mode', 'audio-preview-mode', 'card-preview-mode');
777
+ }
778
+
779
+ function listenMediaPreview(media, eventName, handler, options) {
780
+ media.addEventListener(eventName, handler, options);
781
+ mediaPreview.listeners.push([eventName, handler]);
782
+ }
783
+
784
+ // The hovering controls fade while a file plays; the player's own events
785
+ // drive that, so a pause from the native controls brings them back.
786
+ function followMediaPlayback(media) {
787
+ listenMediaPreview(media, 'play', () => viewerControls.setPlaying(true));
788
+ listenMediaPreview(media, 'pause', () => viewerControls.setPlaying(false));
789
+ listenMediaPreview(media, 'ended', () => viewerControls.setPlaying(false));
790
+ }
791
+
792
+ const viewerControls = createControlsState({
793
+ onChange: (visible) => {
794
+ document.getElementById('file-preview-modal')?.classList.toggle('controls-hidden', !visible);
795
+ },
796
+ });
797
+ const viewerTap = createTapRecognizer({
798
+ onTap: () => viewerControls.toggle(),
799
+ hasSelection: () => String(window.getSelection?.()?.toString() || '').length > 0,
800
+ });
801
+
802
+ /** The chrome's height goes into a CSS variable so text starts below it. */
803
+ function syncViewerChromeHeight() {
804
+ const modal = document.getElementById('file-preview-modal');
805
+ const chromeTop = document.getElementById('file-preview-chrome-top');
806
+ if (!modal || !chromeTop || !modal.classList.contains('visible')) return;
807
+ modal.style.setProperty('--file-viewer-chrome-top', `${Math.ceil(chromeTop.getBoundingClientRect().height)}px`);
808
+ }
809
+
810
+ function showFilePreviewModal() {
811
+ const modal = document.getElementById('file-preview-modal');
812
+ const wasVisible = modal.classList.contains('visible');
813
+ modal.classList.add('visible');
814
+ modal.setAttribute('aria-hidden', 'false');
815
+ if (!wasVisible) {
816
+ viewerControls.reset();
817
+ viewerTap.cancel();
818
+ viewerHistory.opened();
819
+ // The composer keeps the keyboard otherwise: keys would type into it
820
+ // under the viewer instead of moving through the gallery.
821
+ const active = document.activeElement;
822
+ if (active && active !== document.body && !modal.contains(active)) active.blur?.();
823
+ }
824
+ }
825
+
826
+ const viewerHistory = createViewerHistory({
827
+ history: window.history,
828
+ onBack: () => closeFilePreview(),
829
+ });
830
+ window.addEventListener('popstate', () => viewerHistory.onPopState());
831
+
832
+ function setViewerMediaMode(kind) {
833
+ document.getElementById('file-preview-modal')?.classList.toggle('media-mode', DARK_PREVIEW_KINDS.has(kind));
834
+ }
835
+
836
+ function fileCardMarkup({ icon, name, meta, actions }) {
837
+ return `
838
+ <div class="file-preview-card">
839
+ <div class="file-preview-card-icon">${icon}</div>
840
+ <div class="file-preview-card-name">${escHtml(name)}</div>
841
+ ${meta ? `<div class="file-preview-card-meta">${escHtml(meta)}</div>` : ''}
842
+ <div class="file-preview-card-actions">${actions}</div>
843
+ </div>`;
844
+ }
845
+
846
+ function downloadAnchorMarkup(download, plainName) {
847
+ const href = String(download?.href || '');
848
+ if (!href) return '';
849
+ return `<a class="file-preview-btn" href="${escHtml(href)}" download="${escHtml(download.downloadName || plainName)}">Download</a>`;
750
850
  }
751
851
 
752
852
  function setupImageZoom(container) {
@@ -798,11 +898,9 @@ let imgZoom = {
798
898
  baseW: 0,
799
899
  baseH: 0,
800
900
  };
801
- let videoPreview = {
802
- videoEl: null,
803
- onLoadedMetadata: null,
804
- onError: null,
805
- onCanPlay: null,
901
+ let mediaPreview = {
902
+ el: null,
903
+ listeners: [],
806
904
  };
807
905
  const IMG_ZOOM_MIN_FLOOR = 0.05;
808
906
  const IMG_ZOOM_MAX = 8;
@@ -812,6 +910,11 @@ function _isAtImgZoomMin() {
812
910
  return Math.abs(imgZoom.scale - imgZoom.minScale) <= IMG_ZOOM_EPS;
813
911
  }
814
912
 
913
+ /** True unless an image is zoomed in, when a sideways drag pans it instead. */
914
+ function imageZoomAtRest() {
915
+ return !imgZoom.container || _isAtImgZoomMin();
916
+ }
917
+
815
918
  /**
816
919
  * The image's laid-out size at scale 1, measured once with the zoom styles
817
920
  * removed. Deriving it from the live rect instead would be circular now that
@@ -1084,10 +1187,19 @@ function _imgZoomOnResize() {
1084
1187
  _recomputeImgZoomMinScale({ resetToMin: _isAtImgZoomMin() });
1085
1188
  }
1086
1189
 
1190
+ function uploadPreviewKind(mimeType) {
1191
+ const type = String(mimeType || '').toLowerCase();
1192
+ if (type.startsWith('image/')) return 'image';
1193
+ if (isVideoMimeType(type)) return 'video';
1194
+ if (isAudioMimeType(type)) return 'audio';
1195
+ if (type === 'application/pdf') return 'pdf';
1196
+ return 'binary';
1197
+ }
1198
+
1087
1199
  export function openUploadedAttachmentViewer(name, contentUrl, mimeType, options = {}) {
1088
- const isImage = String(mimeType || '').startsWith('image/');
1089
- const isVideo = isVideoMimeType(mimeType);
1090
1200
  const viewerOptions = normalizeVideoPreviewOptions(options);
1201
+ filePreviewLoadSeq += 1;
1202
+ filePreviewHistory.length = 0;
1091
1203
  setFilePreviewState({
1092
1204
  path: String(name || 'attachment'),
1093
1205
  source: 'upload',
@@ -1096,18 +1208,34 @@ export function openUploadedAttachmentViewer(name, contentUrl, mimeType, options
1096
1208
  loading: false,
1097
1209
  error: '',
1098
1210
  viewerOptions,
1211
+ gallery: options?.gallery || null,
1099
1212
  payload: {
1100
- kind: isImage ? 'image' : (isVideo ? 'video' : 'binary'),
1213
+ kind: uploadPreviewKind(mimeType),
1101
1214
  name: String(name || 'attachment'),
1102
1215
  rawUrl: String(contentUrl || ''),
1103
1216
  size: 0,
1104
1217
  contentType: String(mimeType || '').toLowerCase(),
1105
1218
  },
1106
1219
  });
1107
- const modal = document.getElementById('file-preview-modal');
1108
- modal.classList.add('visible');
1109
- modal.setAttribute('aria-hidden', 'false');
1220
+ showFilePreviewModal();
1110
1221
  renderFilePreview();
1222
+ prefetchGalleryNeighbors();
1223
+ }
1224
+
1225
+ /**
1226
+ * Open one item of a gallery: an uploaded attachment by its address, a
1227
+ * drive or workspace file by its path. The gallery rides along so the
1228
+ * viewer can move on from there.
1229
+ */
1230
+ export function openGalleryItem(item, gallery) {
1231
+ if (!item) return Promise.resolve(false);
1232
+ const source = item.source || gallery?.source || 'workspace';
1233
+ if (source === 'upload') {
1234
+ openUploadedAttachmentViewer(item.name, item.href, item.mime, { gallery });
1235
+ return Promise.resolve(true);
1236
+ }
1237
+ const open = source === 'workspace' ? openWorkspaceFilePreview : openDriveFilePreview;
1238
+ return open(item.path, { gallery, viaGallery: true }).then((opened) => Boolean(opened));
1111
1239
  }
1112
1240
 
1113
1241
  export function setFilePreviewMode(mode) {
@@ -1123,25 +1251,38 @@ export function toggleFilePreviewHtml() {
1123
1251
  renderFilePreview();
1124
1252
  }
1125
1253
 
1254
+ /** The address the browser fetches a previewed file's bytes from. */
1255
+ function previewRawHref(source, path, payload) {
1256
+ if (source === 'drives' || source === 'session') return driveFileHrefFromPath(path, payload?.version);
1257
+ if (source === 'upload') return String(payload?.rawUrl || '');
1258
+ return `${BASE}/api/files/${String(path || '').split('/').map((s) => encodeURIComponent(s)).join('/')}${currentWorkspaceScopeSuffix()}`;
1259
+ }
1260
+
1126
1261
  export function renderFilePreview() {
1127
1262
  teardownImageZoom();
1128
- teardownVideoPreview();
1263
+ teardownMediaPreview();
1264
+ viewerTap.cancel();
1265
+ setViewerMediaMode(filePreviewState.payload?.kind);
1266
+ requestAnimationFrame(syncViewerChromeHeight);
1129
1267
  const titleEl = document.getElementById('file-preview-title');
1130
1268
  const metaEl = document.getElementById('file-preview-meta');
1131
1269
  const bodyEl = document.getElementById('file-preview-body');
1132
1270
  const rawLink = document.getElementById('file-preview-open-raw');
1133
1271
  const payload = filePreviewState.payload;
1134
- const rawHref = filePreviewState.source === 'drives'
1135
- || filePreviewState.source === 'session'
1136
- ? driveFileHrefFromPath(filePreviewState.path)
1137
- : filePreviewState.source === 'upload'
1138
- ? String(payload?.rawUrl || '')
1139
- : `${BASE}/api/files/${filePreviewState.path.split('/').map((s) => encodeURIComponent(s)).join('/')}${currentWorkspaceScopeSuffix()}`;
1140
- rawLink.href = rawHref || '#';
1272
+ const rawHref = previewRawHref(filePreviewState.source, filePreviewState.path, payload);
1141
1273
  const fallbackName = String(filePreviewState.path || '').split('/').filter(Boolean).pop() || 'download';
1142
- rawLink.setAttribute('download', String(payload?.name || fallbackName));
1274
+ const plainName = String(payload?.name || fallbackName);
1275
+ // An upload is addressed by its content and never changes; a file on disk
1276
+ // does, so its link and the name it is saved under carry its version.
1277
+ const download = filePreviewState.source === 'upload'
1278
+ ? { href: rawHref, downloadName: plainName }
1279
+ : buildDownloadLink({ href: rawHref, name: plainName, version: payload?.version, mtimeMs: payload?.mtimeMs });
1280
+ rawLink.href = download.href || '#';
1281
+ rawLink.setAttribute('download', download.downloadName || plainName);
1143
1282
  rawLink.textContent = 'Download';
1144
- rawLink.setAttribute('title', 'Download file');
1283
+ rawLink.setAttribute('title', download.downloadName && download.downloadName !== plainName
1284
+ ? `Download as ${download.downloadName}`
1285
+ : 'Download file');
1145
1286
 
1146
1287
  const titlePath = filePreviewState.path || '';
1147
1288
  titleEl.textContent = titlePath || 'File preview';
@@ -1178,8 +1319,56 @@ export function renderFilePreview() {
1178
1319
  const truncatedLabel = payload.truncated ? ` · truncated to ${formatBytes(FILE_PREVIEW_MAX_BYTES)}` : '';
1179
1320
  metaEl.textContent = `${kindLabel}${langLabel} · ${formatBytes(payload.size || 0)}${truncatedLabel}`;
1180
1321
 
1322
+ const cardMeta = payload.size > 0 ? formatBytes(payload.size) : '';
1323
+
1181
1324
  if (payload.kind === 'binary') {
1182
- bodyEl.innerHTML = '<div class="file-preview-note">Binary file preview is not shown. Use <b>Download</b> to save the file.</div>';
1325
+ bodyEl.innerHTML = fileCardMarkup({
1326
+ icon: '📦',
1327
+ name: plainName,
1328
+ meta: cardMeta ? `${cardMeta} · no inline preview` : 'no inline preview',
1329
+ actions: downloadAnchorMarkup(download, plainName),
1330
+ });
1331
+ bodyEl.classList.add('card-preview-mode');
1332
+ return;
1333
+ }
1334
+
1335
+ if (payload.kind === 'pdf') {
1336
+ // The browser's own PDF viewer takes it in a new tab; the plain address
1337
+ // (no `as=` name) keeps the answer inline there.
1338
+ const inlineHref = String(rawHref || payload.rawUrl || '');
1339
+ bodyEl.innerHTML = fileCardMarkup({
1340
+ icon: '📄',
1341
+ name: plainName,
1342
+ meta: cardMeta ? `PDF · ${cardMeta}` : 'PDF',
1343
+ actions: `${inlineHref ? `<button type="button" class="file-preview-btn" data-viewer-open-tab="${escHtml(inlineHref)}">Open in new tab</button>` : ''}${downloadAnchorMarkup(download, plainName)}`,
1344
+ });
1345
+ bodyEl.classList.add('card-preview-mode');
1346
+ return;
1347
+ }
1348
+
1349
+ if (payload.kind === 'audio') {
1350
+ const audioHref = String(rawHref || payload.rawUrl || '');
1351
+ bodyEl.innerHTML = `
1352
+ <div class="file-preview-audio-shell">
1353
+ ${fileCardMarkup({
1354
+ icon: '🎵',
1355
+ name: plainName,
1356
+ meta: cardMeta,
1357
+ actions: audioHref ? `<audio class="file-preview-audio" controls preload="metadata" src="${escHtml(audioHref)}"></audio>` : '<span class="file-preview-note">Audio preview unavailable.</span>',
1358
+ })}
1359
+ </div>`;
1360
+ bodyEl.classList.add('audio-preview-mode');
1361
+ const audio = bodyEl.querySelector('audio');
1362
+ if (audio) {
1363
+ mediaPreview.el = audio;
1364
+ listenMediaPreview(audio, 'error', () => {
1365
+ const note = document.createElement('div');
1366
+ note.className = 'file-preview-note';
1367
+ note.innerHTML = 'Audio preview unavailable. Use <b>Download</b> to save the file.';
1368
+ audio.replaceWith(note);
1369
+ }, { once: true });
1370
+ followMediaPlayback(audio);
1371
+ }
1183
1372
  return;
1184
1373
  }
1185
1374
 
@@ -1197,13 +1386,6 @@ export function renderFilePreview() {
1197
1386
  return;
1198
1387
  }
1199
1388
 
1200
- if (filePreviewState.mode === 'raw') {
1201
- bodyEl.innerHTML = payload.kind === 'video'
1202
- ? '<div class="file-preview-note">Video files are binary. Use <b>Download</b> to save the file.</div>'
1203
- : `<div class="file-preview-code"><pre><code>${escHtml(String(payload.content || ''))}</code></pre></div>`;
1204
- return;
1205
- }
1206
-
1207
1389
  if (payload.kind === 'video') {
1208
1390
  const videoHref = String(rawHref || payload.rawUrl || '');
1209
1391
  const viewerOptions = filePreviewState.viewerOptions || {};
@@ -1214,35 +1396,30 @@ export function renderFilePreview() {
1214
1396
  bodyEl.innerHTML = `
1215
1397
  <div class="file-preview-video-shell" data-start-seconds="${escHtml(String(startSeconds))}" data-preload="${escHtml(preload)}">
1216
1398
  <video class="file-preview-video" controls playsinline preload="${escHtml(preload)}" src="${escHtml(videoHref)}"></video>
1217
- <div class="file-preview-note">${startSeconds > 0 ? `Will start at ${startSeconds.toFixed(2)}s.` : 'Video preview ready.'} ${preload === 'auto' ? 'Preloading enabled.' : 'Metadata preload enabled.'}</div>
1218
1399
  </div>`;
1219
1400
  bodyEl.classList.add('video-preview-mode');
1220
1401
  const shell = bodyEl.querySelector('.file-preview-video-shell');
1221
1402
  const video = shell?.querySelector('video');
1222
1403
  if (video) {
1223
- videoPreview.videoEl = video;
1224
- videoPreview.onLoadedMetadata = () => {
1404
+ mediaPreview.el = video;
1405
+ const tryPlay = () => {
1406
+ const playPromise = video.play?.();
1407
+ if (playPromise && typeof playPromise.catch === 'function') {
1408
+ playPromise.catch(() => {});
1409
+ }
1410
+ };
1411
+ listenMediaPreview(video, 'loadedmetadata', () => {
1225
1412
  if (startSeconds > 0 && Number.isFinite(video.duration) && video.duration > startSeconds) {
1226
1413
  try {
1227
1414
  video.currentTime = startSeconds;
1228
1415
  } catch {}
1229
1416
  }
1230
- if (autoplay) {
1231
- const playPromise = video.play?.();
1232
- if (playPromise && typeof playPromise.catch === 'function') {
1233
- playPromise.catch(() => {});
1234
- }
1235
- }
1236
- };
1237
- videoPreview.onCanPlay = () => {
1238
- if (autoplay && video.paused) {
1239
- const playPromise = video.play?.();
1240
- if (playPromise && typeof playPromise.catch === 'function') {
1241
- playPromise.catch(() => {});
1242
- }
1243
- }
1244
- };
1245
- videoPreview.onError = () => {
1417
+ if (autoplay) tryPlay();
1418
+ }, { once: true });
1419
+ listenMediaPreview(video, 'canplay', () => {
1420
+ if (autoplay && video.paused) tryPlay();
1421
+ });
1422
+ listenMediaPreview(video, 'error', () => {
1246
1423
  let errorNote = bodyEl.querySelector('.file-preview-video-error');
1247
1424
  if (!errorNote) {
1248
1425
  errorNote = document.createElement('div');
@@ -1250,19 +1427,22 @@ export function renderFilePreview() {
1250
1427
  bodyEl.appendChild(errorNote);
1251
1428
  }
1252
1429
  errorNote.innerHTML = 'Video preview unavailable. Use <b>Download</b> to save the file.';
1253
- };
1254
- video.addEventListener('loadedmetadata', videoPreview.onLoadedMetadata, { once: true });
1255
- video.addEventListener('canplay', videoPreview.onCanPlay);
1256
- video.addEventListener('error', videoPreview.onError, { once: true });
1430
+ }, { once: true });
1431
+ followMediaPlayback(video);
1257
1432
  }
1258
1433
  } else {
1259
1434
  bodyEl.innerHTML = '<div class="file-preview-note">Video preview unavailable.</div>';
1260
1435
  }
1261
1436
  return;
1262
1437
  }
1263
-
1438
+
1264
1439
  const rawText = String(payload.content || '');
1265
1440
 
1441
+ if (filePreviewState.mode === 'raw') {
1442
+ bodyEl.innerHTML = `<div class="file-preview-code"><pre><code>${escHtml(rawText)}</code></pre></div>`;
1443
+ return;
1444
+ }
1445
+
1266
1446
  if (payload.kind === 'markdown') {
1267
1447
  const html = renderMarkdownPreview(rawText, filePreviewState.allowHtml);
1268
1448
  bodyEl.innerHTML = `<article class="file-preview-markdown">${html}</article>`;
@@ -1285,92 +1465,173 @@ export function renderFilePreview() {
1285
1465
  attachCodeCopyButtons(bodyEl);
1286
1466
  }
1287
1467
 
1288
- export async function openWorkspaceFilePreview(rawPath, options = {}) {
1289
- const normalized = normalizeWorkspaceMentionPath(rawPath);
1290
- if (!normalized) return;
1468
+ // Two files asked for in a row (a quick swipe, say) must not race: only the
1469
+ // answer to the latest request is shown.
1470
+ let filePreviewLoadSeq = 0;
1471
+
1472
+ async function openFilePreviewFromSource(source, normalized, options, fetchPayload) {
1291
1473
  const viewerOptions = { ...normalizeVideoPreviewOptions(options), fragment: String(options?.fragment || '') };
1292
1474
  if (!options?.fromViewerLink) filePreviewHistory.length = 0;
1475
+ const seq = ++filePreviewLoadSeq;
1293
1476
  setFilePreviewState({
1294
1477
  path: normalized,
1295
- source: 'workspace',
1478
+ source,
1296
1479
  mode: 'preview',
1297
1480
  allowHtml: true,
1298
1481
  loading: true,
1299
1482
  error: '',
1300
1483
  payload: null,
1301
1484
  viewerOptions,
1485
+ gallery: options?.gallery || null,
1302
1486
  });
1303
- const modal = document.getElementById('file-preview-modal');
1304
- modal.classList.add('visible');
1305
- modal.setAttribute('aria-hidden', 'false');
1487
+ showFilePreviewModal();
1306
1488
  renderFilePreview();
1307
1489
 
1308
- const payload = await loadWorkspaceFilePreview(normalized, currentConversationId());
1490
+ // A step through the gallery takes the neighbour fetched ahead of time.
1491
+ const cached = options?.viaGallery ? galleryPayloadCache.get(galleryCacheKey(source, normalized)) : null;
1492
+ const payload = await (cached || fetchPayload());
1493
+ if (seq !== filePreviewLoadSeq) return false;
1309
1494
  if (!payload || payload.error) {
1310
1495
  filePreviewState.loading = false;
1311
- filePreviewState.error = payload?.error || 'Failed to load file preview';
1496
+ filePreviewState.error = payload?.error || `Failed to load ${source === 'workspace' ? '' : 'drive '}file preview`;
1312
1497
  renderFilePreview();
1313
1498
  return false;
1314
1499
  }
1500
+ rememberGalleryPayload(source, normalized, payload);
1315
1501
  filePreviewState.loading = false;
1316
1502
  filePreviewState.payload = payload;
1317
1503
  filePreviewState.path = String(payload.path || normalized);
1504
+ filePreviewState.source = source;
1318
1505
  filePreviewState.viewerOptions = viewerOptions;
1319
1506
  renderFilePreview();
1507
+ prefetchGalleryNeighbors();
1320
1508
  return true;
1321
1509
  }
1322
1510
 
1511
+ export async function openWorkspaceFilePreview(rawPath, options = {}) {
1512
+ const normalized = normalizeWorkspaceMentionPath(rawPath);
1513
+ if (!normalized) return;
1514
+ return openFilePreviewFromSource('workspace', normalized, options,
1515
+ () => loadWorkspaceFilePreview(normalized, currentConversationId()));
1516
+ }
1517
+
1323
1518
  export async function openDriveFilePreview(rawPath, options = {}) {
1324
1519
  const normalized = normalizeDriveBrowserPath(rawPath);
1325
1520
  if (!normalized) return;
1326
- const viewerOptions = { ...normalizeVideoPreviewOptions(options), fragment: String(options?.fragment || '') };
1327
- if (!options?.fromViewerLink) filePreviewHistory.length = 0;
1328
- setFilePreviewState({
1329
- path: normalized,
1330
- source: 'drives',
1331
- mode: 'preview',
1332
- allowHtml: true,
1333
- loading: true,
1334
- error: '',
1335
- payload: null,
1336
- viewerOptions,
1337
- });
1338
- const modal = document.getElementById('file-preview-modal');
1339
- modal.classList.add('visible');
1340
- modal.setAttribute('aria-hidden', 'false');
1341
- renderFilePreview();
1521
+ return openFilePreviewFromSource('drives', normalized, options, () => loadDriveFilePreview(normalized));
1522
+ }
1342
1523
 
1343
- const payload = await loadDriveFilePreview(normalized);
1344
- if (!payload || payload.error) {
1345
- filePreviewState.loading = false;
1346
- filePreviewState.error = payload?.error || 'Failed to load drive file preview';
1347
- renderFilePreview();
1348
- return false;
1349
- }
1350
- filePreviewState.loading = false;
1351
- filePreviewState.payload = payload;
1352
- filePreviewState.path = String(payload.path || normalized);
1353
- filePreviewState.source = 'drives';
1354
- filePreviewState.viewerOptions = viewerOptions;
1355
- renderFilePreview();
1356
- return true;
1524
+ /** The files of the browser's current folder, as a gallery open at `openedPath`. */
1525
+ function repoFolderGallery(openedPath) {
1526
+ const node = repoBrowserState.nodeMap.get(String(repoBrowserState.currentPath || '')) || null;
1527
+ if (!node || node.type !== 'dir' || !Array.isArray(node.children)) return null;
1528
+ return galleryFromFolder(node.children, openedPath, {
1529
+ source: repoBrowserState.activeRoot === 'workspace' ? 'workspace' : 'drives',
1530
+ });
1357
1531
  }
1358
1532
 
1359
1533
  export async function openWorkspaceFilePreviewFromRepo(rawPath, options = {}) {
1534
+ const gallery = repoFolderGallery(rawPath);
1535
+ const openOptions = gallery ? { ...options, gallery } : options;
1360
1536
  if (repoBrowserState.activeRoot !== 'workspace') {
1361
- await openDriveFilePreview(rawPath, options);
1537
+ await openDriveFilePreview(rawPath, openOptions);
1362
1538
  return;
1363
1539
  }
1364
- await openWorkspaceFilePreview(rawPath, options);
1540
+ await openWorkspaceFilePreview(rawPath, openOptions);
1541
+ }
1542
+
1543
+ /** Show the file `delta` steps along the gallery; false at either end. */
1544
+ export async function stepFilePreviewGallery(delta) {
1545
+ const gallery = filePreviewState.gallery;
1546
+ const index = galleryNeighborIndex(gallery, delta);
1547
+ if (index === -1) return false;
1548
+ const next = galleryAt(gallery, index);
1549
+ return openGalleryItem(next.items[index], next);
1550
+ }
1551
+
1552
+ // Preview payloads of the open file's neighbours, fetched ahead so a swipe
1553
+ // lands on the file and not on "Fetching…"; an image's bytes are asked for
1554
+ // too, under the very address the viewer will use. Entries are promises.
1555
+ const galleryPayloadCache = new Map();
1556
+ const GALLERY_PAYLOAD_CACHE_MAX = 12;
1557
+
1558
+ function galleryCacheKey(source, path) {
1559
+ return `${source}:${path}`;
1560
+ }
1561
+
1562
+ function rememberGalleryPayload(source, path, payload) {
1563
+ const key = galleryCacheKey(source, path);
1564
+ galleryPayloadCache.delete(key);
1565
+ galleryPayloadCache.set(key, Promise.resolve(payload));
1566
+ while (galleryPayloadCache.size > GALLERY_PAYLOAD_CACHE_MAX) {
1567
+ galleryPayloadCache.delete(galleryPayloadCache.keys().next().value);
1568
+ }
1569
+ }
1570
+
1571
+ function prefetchGalleryNeighbors() {
1572
+ const gallery = filePreviewState.gallery;
1573
+ if (!gallery) return;
1574
+ for (const item of galleryNeighbors(gallery)) {
1575
+ const source = item.source || gallery.source;
1576
+ if (source === 'upload') {
1577
+ // An attachment has no preview to fetch; its picture can be primed.
1578
+ if (String(item.mime || '').startsWith('image/') && item.href) {
1579
+ const image = new Image();
1580
+ image.src = item.href;
1581
+ }
1582
+ continue;
1583
+ }
1584
+ const normalized = source === 'workspace'
1585
+ ? normalizeWorkspaceMentionPath(item.path)
1586
+ : normalizeDriveBrowserPath(item.path);
1587
+ if (!normalized) continue;
1588
+ const key = galleryCacheKey(source, normalized);
1589
+ if (galleryPayloadCache.has(key)) continue;
1590
+ const request = (source === 'workspace'
1591
+ ? loadWorkspaceFilePreview(normalized, currentConversationId())
1592
+ : loadDriveFilePreview(normalized)
1593
+ ).then((payload) => {
1594
+ if (!payload || payload.error) {
1595
+ galleryPayloadCache.delete(key);
1596
+ return null;
1597
+ }
1598
+ if (payload.kind === 'image') {
1599
+ const image = new Image();
1600
+ image.src = previewRawHref(source, String(payload.path || normalized), payload);
1601
+ }
1602
+ return payload;
1603
+ }).catch(() => {
1604
+ galleryPayloadCache.delete(key);
1605
+ return null;
1606
+ });
1607
+ rememberGalleryPayload(source, normalized, request);
1608
+ }
1365
1609
  }
1366
1610
 
1367
1611
  export function closeFilePreview() {
1368
1612
  teardownImageZoom();
1369
- teardownVideoPreview();
1613
+ teardownMediaPreview();
1614
+ viewerTap.cancel();
1615
+ viewerControls.reset();
1370
1616
  const modal = document.getElementById('file-preview-modal');
1371
- modal.classList.remove('visible');
1617
+ const wasVisible = modal.classList.contains('visible');
1618
+ modal.classList.remove('visible', 'controls-hidden', 'media-mode');
1372
1619
  modal.setAttribute('aria-hidden', 'true');
1373
1620
  filePreviewHistory.length = 0;
1621
+ filePreviewState.gallery = null;
1622
+ galleryPayloadCache.clear();
1623
+ viewerSwipe.cancel();
1624
+ clearViewerDragStyles();
1625
+ if (wasVisible) viewerHistory.closed();
1626
+ }
1627
+
1628
+ function clearViewerDragStyles() {
1629
+ const bodyEl = document.getElementById('file-preview-body');
1630
+ const modal = document.getElementById('file-preview-modal');
1631
+ bodyEl.style.transform = '';
1632
+ bodyEl.style.transition = '';
1633
+ modal.style.opacity = '';
1634
+ modal.style.transition = '';
1374
1635
  }
1375
1636
 
1376
1637
  export function goBackFilePreview() {
@@ -2175,11 +2436,133 @@ document.addEventListener('click', (event) => {
2175
2436
  void openWorkspaceFilePreview(anchor.dataset.workspacePath || '');
2176
2437
  });
2177
2438
 
2178
- document.getElementById('file-preview-modal').addEventListener('click', (event) => {
2179
- if (event.target.id === 'file-preview-modal') closeFilePreview();
2439
+ // A tap on the content shows or hides the floating controls; the recognizer
2440
+ // leaves drags, pinches, double taps and the content's own buttons alone.
2441
+ // While something plays, a mouse moving over the viewer brings the controls
2442
+ // back as players do; over a still image a click is the only switch, or a
2443
+ // nudge of the mouse would undo every click that hid them.
2444
+ for (const eventName of ['pointerdown', 'pointermove', 'pointerup', 'pointercancel']) {
2445
+ document.getElementById('file-preview-body').addEventListener(eventName, (event) => {
2446
+ viewerTap.handle(event);
2447
+ viewerSwipe.handle(event);
2448
+ if (eventName === 'pointermove' && event.pointerType === 'mouse' && viewerControls.playing) {
2449
+ viewerControls.interaction();
2450
+ }
2451
+ });
2452
+ }
2453
+ for (const id of ['file-preview-chrome-top', 'file-preview-chrome-bottom']) {
2454
+ document.getElementById(id).addEventListener('pointerdown', () => viewerControls.interaction());
2455
+ }
2456
+ window.addEventListener('resize', syncViewerChromeHeight);
2457
+
2458
+ // Moving through the gallery: the buttons below, the arrow keys, and a
2459
+ // sideways swipe of a finger (a mouse drag selects text and stays a drag).
2460
+ document.getElementById('file-preview-prev').addEventListener('click', () => { void stepFilePreviewGallery(-1); });
2461
+ document.getElementById('file-preview-next').addEventListener('click', () => { void stepFilePreviewGallery(1); });
2462
+
2463
+ function isEditableTarget(target) {
2464
+ return Boolean(target?.closest?.('input, textarea, select, [contenteditable]'));
2465
+ }
2466
+
2467
+ document.addEventListener('keydown', (event) => {
2468
+ if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return;
2469
+ const modal = document.getElementById('file-preview-modal');
2470
+ if (!modal.classList.contains('visible')) return;
2471
+ if (document.getElementById('image-annotate-modal')?.classList.contains('visible')) return;
2472
+ if ((isEditableTarget(event.target) && modal.contains(event.target)) || !filePreviewState.gallery) return;
2473
+ event.preventDefault();
2474
+ void stepFilePreviewGallery(event.key === 'ArrowRight' ? 1 : -1);
2475
+ });
2476
+
2477
+ /** Something between `target` and the viewer body scrolls sideways itself. */
2478
+ function insideHorizontalScroller(target) {
2479
+ const bodyEl = document.getElementById('file-preview-body');
2480
+ for (let el = target instanceof Element ? target : null; el && el !== bodyEl; el = el.parentElement) {
2481
+ if (el.scrollWidth > el.clientWidth + 1) {
2482
+ const overflowX = getComputedStyle(el).overflowX;
2483
+ if (overflowX === 'auto' || overflowX === 'scroll') return true;
2484
+ }
2485
+ }
2486
+ return false;
2487
+ }
2488
+
2489
+ // A swipe down dismisses what does not scroll (a picture, a player, a card):
2490
+ // the content follows the finger and the viewer fades, then goes if the
2491
+ // finger went far or fast enough. Text scrolls under the finger instead and
2492
+ // is closed with ×, Escape or the back gesture.
2493
+ const DISMISS_FADE_PX = 480;
2494
+ const DISMISS_ANIMATION_MS = 200;
2495
+ let viewerDismissing = false;
2496
+
2497
+ function viewerDismissableBySwipe() {
2498
+ return !viewerDismissing && !TEXT_PREVIEW_KINDS.has(filePreviewState.payload?.kind) && imageZoomAtRest();
2499
+ }
2500
+
2501
+ function dismissFilePreviewWithSlide() {
2502
+ const bodyEl = document.getElementById('file-preview-body');
2503
+ const modal = document.getElementById('file-preview-modal');
2504
+ viewerDismissing = true;
2505
+ bodyEl.style.transition = `transform ${DISMISS_ANIMATION_MS}ms ease-in`;
2506
+ modal.style.transition = `opacity ${DISMISS_ANIMATION_MS}ms ease-in`;
2507
+ bodyEl.style.transform = 'translateY(100vh)';
2508
+ modal.style.opacity = '0';
2509
+ setTimeout(() => {
2510
+ viewerDismissing = false;
2511
+ closeFilePreview();
2512
+ }, DISMISS_ANIMATION_MS);
2513
+ }
2514
+
2515
+ const viewerSwipe = createSwipeRecognizer({
2516
+ canStart: (event) => event.pointerType !== 'mouse'
2517
+ && !viewerDismissing
2518
+ && !event.target?.closest?.('video, audio, input, textarea, select, [contenteditable]')
2519
+ && !insideHorizontalScroller(event.target),
2520
+ horizontalEnabled: () => Boolean(filePreviewState.gallery) && imageZoomAtRest(),
2521
+ verticalEnabled: viewerDismissableBySwipe,
2522
+ onStart: () => {
2523
+ const bodyEl = document.getElementById('file-preview-body');
2524
+ bodyEl.style.transition = 'none';
2525
+ document.getElementById('file-preview-modal').style.transition = 'none';
2526
+ },
2527
+ onMove: ({ axis, dx, dy }) => {
2528
+ const bodyEl = document.getElementById('file-preview-body');
2529
+ if (axis === 'y') {
2530
+ // Upwards there is nothing: the content barely gives.
2531
+ const shift = dy < 0 ? dy * 0.2 : dy;
2532
+ bodyEl.style.transform = `translateY(${shift}px)`;
2533
+ document.getElementById('file-preview-modal').style.opacity = String(Math.max(0.3, 1 - Math.max(0, dy) / DISMISS_FADE_PX));
2534
+ return;
2535
+ }
2536
+ // Past either end the content gives a little, then holds.
2537
+ const toward = galleryNeighborIndex(filePreviewState.gallery, dx < 0 ? 1 : -1);
2538
+ const shift = toward === -1 ? dx * 0.25 : dx;
2539
+ bodyEl.style.transform = `translateX(${shift}px)`;
2540
+ },
2541
+ onEnd: ({ axis, swipe }) => {
2542
+ if (axis === 'y' && swipe === 'down') {
2543
+ dismissFilePreviewWithSlide();
2544
+ return;
2545
+ }
2546
+ const bodyEl = document.getElementById('file-preview-body');
2547
+ const modal = document.getElementById('file-preview-modal');
2548
+ bodyEl.style.transition = 'transform 180ms ease';
2549
+ modal.style.transition = 'opacity 180ms ease';
2550
+ bodyEl.style.transform = '';
2551
+ modal.style.opacity = '';
2552
+ if (swipe === 'left') void stepFilePreviewGallery(1);
2553
+ else if (swipe === 'right') void stepFilePreviewGallery(-1);
2554
+ },
2180
2555
  });
2181
2556
 
2182
2557
  document.getElementById('file-preview-body').addEventListener('click', (event) => {
2558
+ const openTab = eventClosest(event, '[data-viewer-open-tab]');
2559
+ if (openTab) {
2560
+ event.preventDefault();
2561
+ openExternalNavigation(openTab.getAttribute('data-viewer-open-tab') || '', (url) => {
2562
+ window.dispatchEvent(new CustomEvent('copilot:external-link-fallback', { detail: { url } }));
2563
+ });
2564
+ return;
2565
+ }
2183
2566
  const anchor = eventClosest(event, '.file-preview-markdown a[href]');
2184
2567
  if (!anchor) return;
2185
2568
  const target = resolveFilePreviewLink(anchor.getAttribute('href'), filePreviewState.path);