@oar-sh/oar 0.9.8 → 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 (41) hide show
  1. package/CHANGELOG.md +231 -0
  2. package/README.md +53 -32
  3. package/bin/oar.js +814 -228
  4. package/package.json +4 -5
  5. package/server/README.md +20 -1
  6. package/server/db-schema.mjs +8 -0
  7. package/server/migrations/0008-message-pins.mjs +85 -0
  8. package/server/public/app/api-client.js +30 -0
  9. package/server/public/app/attachments-view.js +508 -125
  10. package/server/public/app/back-guard.mjs +58 -0
  11. package/server/public/app/bootstrap.js +46 -2
  12. package/server/public/app/conversation-view.js +110 -25
  13. package/server/public/app/external-link-policy.mjs +4 -1
  14. package/server/public/app/file-version.mjs +58 -0
  15. package/server/public/app/journal-view.js +16 -2
  16. package/server/public/app/pinned-messages-view.mjs +253 -0
  17. package/server/public/app/pwa-install.js +1 -1
  18. package/server/public/app/relay-dot-state.mjs +8 -1
  19. package/server/public/app/router.js +8 -2
  20. package/server/public/app/socket-handlers.js +6 -0
  21. package/server/public/app/store.js +6 -0
  22. package/server/public/app/viewer-chat-gallery.mjs +59 -0
  23. package/server/public/app/viewer-controls-state.mjs +59 -0
  24. package/server/public/app/viewer-gallery.mjs +46 -0
  25. package/server/public/app/viewer-gestures.mjs +105 -0
  26. package/server/public/app/viewer-history.mjs +43 -0
  27. package/server/public/app/viewer-tap.mjs +112 -0
  28. package/server/public/index.html +304 -54
  29. package/server/repositories/message-pin-statements.mjs +39 -0
  30. package/server/repositories/message-repository.mjs +5 -0
  31. package/server/routes/messages-routes.mjs +33 -61
  32. package/server/routes/sessions-routes.mjs +92 -24
  33. package/server/server-runtime.mjs +29 -0
  34. package/server/services/cloudflared-tunnel-service.mjs +139 -41
  35. package/server/services/file-serving.mjs +155 -0
  36. package/server/services/message-pin-list.mjs +52 -0
  37. package/server/services/oar-cli-helpers.mjs +139 -4
  38. package/server/services/oar-cli-menu.mjs +240 -0
  39. package/server/services/oar-cli-wsl.mjs +129 -0
  40. package/server/services/safe-served-content.mjs +61 -11
  41. package/server/services/session-history-refresh-service.mjs +24 -0
@@ -0,0 +1,58 @@
1
+ // In the installed app, the system back button with nothing left to go back
2
+ // to closes the app. There is no way for a page to send itself to the
3
+ // background instead, but it can make sure that moment never comes: one
4
+ // entry marked as the floor is kept beneath the app's own entry, and when
5
+ // back lands on the floor the app's entry is pushed again at once. Back
6
+ // still closes the file viewer (which pushes its own entry above); only the
7
+ // last step out is swallowed. A browser tab keeps its back button: this is
8
+ // for the installed app alone.
9
+
10
+ export const BACK_GUARD_FLOOR = 'oarBackGuardFloor';
11
+ export const BACK_GUARD_APP = 'oarApp';
12
+ const FIRST_INTERACTION_EVENTS = ['pointerdown', 'keydown', 'touchstart'];
13
+
14
+ /**
15
+ * Lay the floor at the first touch or key, not at start-up: an entry a page
16
+ * leaves through pushState before the user has done anything is one the
17
+ * browser skips on back (its guard against pages that hijack the button),
18
+ * so a floor laid at start-up would be stepped over. Before the first
19
+ * interaction nothing a page does keeps back from leaving; from then on
20
+ * the floor holds.
21
+ */
22
+ export function armBackGuardOnFirstInteraction({ history, enabled = true, target, onFloor = null } = {}) {
23
+ if (!enabled || !history || !target) return null;
24
+ const state = { guard: null };
25
+ const arm = () => {
26
+ for (const eventName of FIRST_INTERACTION_EVENTS) target.removeEventListener(eventName, arm, true);
27
+ if (state.guard) return;
28
+ state.guard = installBackGuard({ history, enabled: true, onFloor });
29
+ target.addEventListener('popstate', () => state.guard.onPopState());
30
+ };
31
+ for (const eventName of FIRST_INTERACTION_EVENTS) target.addEventListener(eventName, arm, true);
32
+ return { get armed() { return Boolean(state.guard); } };
33
+ }
34
+
35
+ /**
36
+ * `onFloor` is told each time back lands on the floor and is swallowed.
37
+ * The entry pushed again then is pushed without a touch, so the browser
38
+ * lets the next back leave unless the user touches the app in between:
39
+ * one swallowed press, then the app closes — the pattern phones use, and
40
+ * the moment to say "press back again to close".
41
+ */
42
+ export function installBackGuard({ history, enabled = true, onFloor = null } = {}) {
43
+ if (!enabled || !history) return null;
44
+ const current = history.state && typeof history.state === 'object' ? history.state : {};
45
+ // After a reload on the app's entry the floor already lies beneath it.
46
+ if (!current[BACK_GUARD_FLOOR] && !current[BACK_GUARD_APP]) {
47
+ history.replaceState({ ...current, [BACK_GUARD_FLOOR]: true }, '');
48
+ history.pushState({ [BACK_GUARD_APP]: true }, '');
49
+ }
50
+ return {
51
+ /** Feed every popstate here. */
52
+ onPopState() {
53
+ if (!history.state?.[BACK_GUARD_FLOOR]) return;
54
+ history.pushState({ [BACK_GUARD_APP]: true }, '');
55
+ onFloor?.();
56
+ },
57
+ };
58
+ }
@@ -122,7 +122,12 @@ import {
122
122
  jumpToImageParent,
123
123
  flushDeferredMessageRender,
124
124
  conversationHasActiveTurn,
125
+ focusConversationMessageById,
126
+ syncRenderedPinState,
127
+ isConversationWindowAtTail,
128
+ getTranscriptJumpEpoch,
125
129
  } from './conversation-view.js';
130
+ import { initPinnedMessagesView, setPinsConversation } from './pinned-messages-view.mjs';
126
131
  import { bindChatSelectionGuard, chatSelectionGuard, isChatInteractionHeld } from './selection-guard.mjs';
127
132
  import { loadRepoBrowserTree, openRepoBrowser, closeRepoBrowser, setRepoBrowserSessionInfo, resetWorkspaceRepoBrowserForRootChange } from './attachments-view.js';
128
133
  import { handleAttachmentInput, retryAttachmentUpload, openAnnotateEditorForPending, openAnnotateEditorForPreview, handleComposerPaste, handleComposerDrop, refreshComposerAttachmentWarning, removeAttachment, clearAttachments, openUploadedAttachmentViewer, setFilePreviewMode, toggleFilePreviewHtml, copyFilePreviewImage, closeFilePreview, goBackFilePreview, openWorkspaceFilePreview, openWorkspaceFilePreviewFromRepo, setRepoBrowserRoot, setRepoBrowserViewMode, toggleRepoBrowserHidden, toggleRepoBrowserHeavy, refreshRepoBrowser, focusRepoTree, setRepoCurrentPath, confirmRepoBrowserCwdPick } from './attachments-view.js';
@@ -198,7 +203,9 @@ import {
198
203
  adoptLegacyPwaAppName,
199
204
  registerPwaShell,
200
205
  updatePwaAppName,
206
+ isInstalledAppMode,
201
207
  } from './pwa-install.js';
208
+ import { armBackGuardOnFirstInteraction } from './back-guard.mjs';
202
209
  import { initScrollbarHover } from './scrollbar-hover.mjs';
203
210
  import {
204
211
  autoCompactWindowNotes,
@@ -3592,6 +3599,15 @@ function startSessionWorkerStatusPolling() {
3592
3599
  }, 4000);
3593
3600
  }
3594
3601
 
3602
+ // The live poll and refreshCurrentView both reload the END of the
3603
+ // conversation. After a jump into the history (a search result, a pinned
3604
+ // message) that would put the end back over the window the user asked for,
3605
+ // until they have scrolled down to the newest message again. A message sent
3606
+ // from that window brings the present back at once: its bubble needs the poll.
3607
+ function transcriptIsInHistory(conversationId) {
3608
+ return !isConversationWindowAtTail() && !hasPendingUserMessageForConversation(conversationId);
3609
+ }
3610
+
3595
3611
  async function pollAuthenticatedCurrentConversationLive() {
3596
3612
  if (liveConversationPollInFlight) return;
3597
3613
  if (isSharedReaderMode()) return;
@@ -3609,6 +3625,7 @@ async function pollAuthenticatedCurrentConversationLive() {
3609
3625
  // Refreshing while the user selects or drags in the chat would rebuild the
3610
3626
  // DOM under the selection; the guard's release callback re-polls right away.
3611
3627
  if (isChatInteractionHeld()) return;
3628
+ if (transcriptIsInHistory(currentId)) return;
3612
3629
 
3613
3630
  liveConversationPollInFlight = true;
3614
3631
  try {
@@ -3617,9 +3634,14 @@ async function pollAuthenticatedCurrentConversationLive() {
3617
3634
  const savedScrollTop = messagesEl?.scrollTop || 0;
3618
3635
  const savedLoadedCount = loadConversationLoadedMessageCount(currentId);
3619
3636
  const requestLimit = Math.max(20, getConversationLoadedMessageCount() || 0, savedLoadedCount || 0);
3637
+ const jumpEpoch = getTranscriptJumpEpoch();
3620
3638
  const response = await loadConversation(currentId, { limit: requestLimit });
3621
3639
  if (!response) return;
3622
3640
  if (String(currentConvId || '').trim() !== currentId) return;
3641
+ // The user jumped into the history while this request was on its way: the
3642
+ // answer is the end of the conversation as it was asked for before that,
3643
+ // and would overwrite the window they jumped to. The next round asks again.
3644
+ if (getTranscriptJumpEpoch() !== jumpEpoch) return;
3623
3645
  const stableSavedScrollTop = resolveStableScrollTopForLiveRefresh(messagesEl, savedScrollTop);
3624
3646
  applyLoadedConversationState(currentId, response, {
3625
3647
  restoreScroll: !preserveBottom,
@@ -3827,6 +3849,7 @@ let refreshViewVersion = 0;
3827
3849
 
3828
3850
  async function refreshCurrentView() {
3829
3851
  const capturedVersion = ++refreshViewVersion;
3852
+ const jumpEpoch = getTranscriptJumpEpoch();
3830
3853
  const messagesEl = document.getElementById('messages');
3831
3854
  const preserveBottom = isMessagesAtBottom();
3832
3855
  const scrollTop = messagesEl?.scrollTop || 0;
@@ -3849,11 +3872,16 @@ async function refreshCurrentView() {
3849
3872
  const r = await loadConversation(currentId, { limit: requestLimit });
3850
3873
  if (capturedVersion < refreshViewVersion) return;
3851
3874
  if (String(currentConvId || '').trim() !== currentId) return;
3875
+ // The user jumped into the history, before this refresh or while it was on
3876
+ // its way: title, tasks, pins and the rest are refreshed, the transcript
3877
+ // and its scroll position are left as they are.
3878
+ const keepTranscript = getTranscriptJumpEpoch() !== jumpEpoch || transcriptIsInHistory(currentId);
3852
3879
  if (r) {
3853
3880
  applyLoadedConversationState(currentId, r, {
3854
3881
  restoreScroll: !preserveBottom,
3855
3882
  savedScrollTop: preserveBottom ? null : stableSavedScrollTop,
3856
- followLiveUpdates: preserveBottom,
3883
+ followLiveUpdates: preserveBottom && !keepTranscript,
3884
+ keepTranscript,
3857
3885
  });
3858
3886
  } else {
3859
3887
  setRepoBrowserSessionInfo('', '');
@@ -3862,7 +3890,7 @@ async function refreshCurrentView() {
3862
3890
  await loadRelayQuestions(currentId);
3863
3891
  await loadRelayBoards();
3864
3892
  scheduleContextUsageRefresh(currentId, 0);
3865
- if (messagesEl && String(currentConvId || '').trim() === currentId) {
3893
+ if (messagesEl && !keepTranscript && String(currentConvId || '').trim() === currentId) {
3866
3894
  if (!preserveBottom && Number.isFinite(stableSavedScrollTop) && stableSavedScrollTop >= 0) {
3867
3895
  messagesEl.scrollTop = stableSavedScrollTop;
3868
3896
  }
@@ -4024,6 +4052,8 @@ function lockChatActionsMenuShield(ms = 300) {
4024
4052
  function syncChatTitleControls() {
4025
4053
  const { title, editBtn, editor, input } = getChatTitleElements();
4026
4054
  const convId = String(currentConvId || '').trim();
4055
+ // No conversation open (deleted, new chat): its 📍 button goes with it.
4056
+ if (!convId) setPinsConversation(null);
4027
4057
  const killBtn = document.getElementById('chat-menu-kill-session');
4028
4058
  const sharedMode = isSharedReaderMode();
4029
4059
  const conversation = convId ? (conversations[convId] || null) : null;
@@ -4571,6 +4601,11 @@ async function initApp() {
4571
4601
  initMessageScrollPersistence();
4572
4602
  initComposerAttachmentInput();
4573
4603
  initMessageSearchView({ openConversation });
4604
+ initPinnedMessagesView({
4605
+ openConversation,
4606
+ focusMessage: focusConversationMessageById,
4607
+ syncRenderedPinState,
4608
+ });
4574
4609
  initGitChangesView();
4575
4610
  syncChatTitleControls();
4576
4611
  connectSocket();
@@ -4627,6 +4662,15 @@ async function bootstrap() {
4627
4662
  }
4628
4663
  const urlToken = getTokenFromUrl();
4629
4664
  if (urlToken) stripTokenFromUrl();
4665
+ // In the installed app the system back button must never close the app;
4666
+ // a tab keeps its own back button. After the token is gone from the
4667
+ // address, so the entries the guard lays down carry the clean one.
4668
+ armBackGuardOnFirstInteraction({
4669
+ history: window.history,
4670
+ enabled: isInstalledAppMode(),
4671
+ target: window,
4672
+ onFloor: () => showTransientRelayNotice('Press back again to close the app', 2500),
4673
+ });
4630
4674
  const persistedToken = consumeLegacyPersistedAuthToken();
4631
4675
  const bootstrapToken = String(urlToken || persistedToken || '').trim();
4632
4676
  const existingSession = await verifyExistingSession(bootstrapToken);
@@ -52,8 +52,10 @@ import { getPendingQuestionCountsByConversation } from './ask-user-view.js';
52
52
  import { sendMessage as sendMessageApi, cancelConversationTurn, cancelQueuedConversationTurn, cancelSubagentRun, compactConversation as compactConversationApi, scheduleContextUsageRefresh, loadConversation as loadConversationApi, loadSharedConversation, updateConversationDraft as updateConversationDraftApi, updateMessageShareVisibility, launchSessionWorker } from './api-client.js';
53
53
  import { enqueueOutboxRequest, registerOutboxSync } from './sync-outbox.mjs';
54
54
  import { linkifyWorkspaceMentionsInNode, parseAppFileHref, renderMarkdownPreview, rewriteLocalAssetUrlsInNode } from './router.js';
55
- import { renderAttachmentMarkup, removeComposerAttachments, uploadAttachments, setComposerAttachments, setRepoBrowserSessionInfo, openDriveFilePreview, openWorkspaceFilePreview, openUploadedAttachmentViewer } from './attachments-view.js';
55
+ import { renderAttachmentMarkup, removeComposerAttachments, uploadAttachments, setComposerAttachments, setRepoBrowserSessionInfo, openGalleryItem } from './attachments-view.js';
56
+ import { chatMediaGallery, chatMediaItemFromNode } from './viewer-chat-gallery.mjs';
56
57
  import { buildWorkflowRunCard } from './background-tasks-view.mjs';
58
+ import { isMessagePinned, toggleMessagePin } from './pinned-messages-view.mjs';
57
59
  import { parsePreviewCommand, runPreviewCommand } from './preview-command.mjs';
58
60
  import { buildTranscriptPreviewCard } from './preview-cards.mjs';
59
61
  import { closeSlashAutocomplete, handleSlashAutocompleteKey, updateSlashAutocomplete } from './slash-autocomplete.mjs';
@@ -1393,8 +1395,13 @@ function createMessageNode(msg, msgId = null, force = false) {
1393
1395
  const belongsToActiveTurn = !!activeTurnMessageId
1394
1396
  && (activeTurnMessageId === msgId || activeTurnMessageId === sourceMessageId);
1395
1397
  const canToggleShareVisibility = !IS_SHARED_VIEW && !!msgId && !isQueuedUserMessage && !belongsToActiveTurn;
1398
+ // From the conversation's pin list, not from the message: the list is
1399
+ // ordered by revision, and a bubble built from a live event has no pin
1400
+ // state of its own.
1401
+ const pinned = isMessagePinned(currentConvId, msgId);
1396
1402
  const shareVisibilityActionHtml = canToggleShareVisibility
1397
1403
  ? `<div class="msg-share-visibility">
1404
+ ${buildMessagePinControlsHtml(msgId, pinned)}
1398
1405
  ${hiddenFromShares ? '<span class="msg-hidden-label">Hidden from shared viewers</span>' : ''}
1399
1406
  <button type="button" class="msg-share-visibility-btn" data-action="toggle-share-visibility" data-message-id="${escHtml(msgId)}" data-hidden-from-shares="${hiddenFromShares ? 'true' : 'false'}" title="${hiddenFromShares ? 'Shows this message in shared conversations' : 'Hides this message from shared conversations'}">${hiddenFromShares ? 'Unhide' : 'Hide'}</button>
1400
1407
  </div>`
@@ -3134,6 +3141,38 @@ async function cancelSubagentByRunId(conversationId, subagentRunId) {
3134
3141
  updateSubagentStopButton(targetSubagentRunId, false);
3135
3142
  }
3136
3143
 
3144
+ const PINNED_LABEL_HTML = '<span class="msg-pinned-label">📍 Pinned</span>';
3145
+
3146
+ function pinButtonTitle(pinned) {
3147
+ return pinned ? 'Removes this message from the pinned list' : 'Adds this message to the pinned list';
3148
+ }
3149
+
3150
+ // The pin half of a bubble's corner row. No whitespace between label and
3151
+ // button: syncRenderedPinState finds the label as the button's sibling.
3152
+ function buildMessagePinControlsHtml(msgId, pinned) {
3153
+ return `${pinned ? PINNED_LABEL_HTML : ''}<button type="button" class="msg-pin-btn" data-action="toggle-pin" data-message-id="${escHtml(msgId)}" data-pinned="${pinned ? 'true' : 'false'}" title="${pinButtonTitle(pinned)}">${pinned ? 'Unpin' : 'Pin'}</button>`;
3154
+ }
3155
+
3156
+ // The pin list changed (this page, another device): bring the bubbles on the
3157
+ // page in line without rebuilding them.
3158
+ export function syncRenderedPinState(pinnedIds) {
3159
+ const el = getMessagesElement();
3160
+ if (!el) return;
3161
+ const ids = pinnedIds instanceof Set ? pinnedIds : new Set(pinnedIds || []);
3162
+ for (const button of el.querySelectorAll('.msg-pin-btn')) {
3163
+ const pinned = ids.has(String(button.dataset.messageId || '').trim());
3164
+ if ((button.dataset.pinned === 'true') === pinned) continue;
3165
+ button.dataset.pinned = pinned ? 'true' : 'false';
3166
+ button.disabled = false;
3167
+ button.textContent = pinned ? 'Unpin' : 'Pin';
3168
+ button.title = pinButtonTitle(pinned);
3169
+ const sibling = button.previousElementSibling;
3170
+ const label = sibling?.classList?.contains('msg-pinned-label') ? sibling : null;
3171
+ if (pinned && !label) button.insertAdjacentHTML('beforebegin', PINNED_LABEL_HTML);
3172
+ if (!pinned && label) label.remove();
3173
+ }
3174
+ }
3175
+
3137
3176
  async function toggleMessageShareVisibility(conversationId, messageId, hiddenFromShares) {
3138
3177
  const conversationKey = String(conversationId || '').trim();
3139
3178
  const targetMessageId = String(messageId || '').trim();
@@ -3192,7 +3231,7 @@ function runRelayErrorCta(cta) {
3192
3231
  }
3193
3232
 
3194
3233
  function handleBubbleActionClick(event) {
3195
- const btn = event.target.closest('.bubble-action-btn, .msg-share-visibility-btn, .thinking-follow-btn, .subagent-bubble-header');
3234
+ const btn = event.target.closest('.bubble-action-btn, .msg-share-visibility-btn, .msg-pin-btn, .thinking-follow-btn, .subagent-bubble-header');
3196
3235
  if (!btn) return;
3197
3236
  const action = btn.dataset.action;
3198
3237
  const messageId = btn.dataset.messageId;
@@ -3214,6 +3253,12 @@ function handleBubbleActionClick(event) {
3214
3253
  );
3215
3254
  }
3216
3255
 
3256
+ if (action === 'toggle-pin' && messageId) {
3257
+ event.preventDefault();
3258
+ event.stopPropagation();
3259
+ void toggleMessagePin(currentConvId, messageId, btn.dataset.pinned === 'true');
3260
+ }
3261
+
3217
3262
  if (action === 'stop-turn' && messageId) {
3218
3263
  event.preventDefault();
3219
3264
  event.stopPropagation();
@@ -3269,30 +3314,48 @@ function handleBubbleActionKeydown(event) {
3269
3314
  toggleSubagentFold(header.dataset.subagentRunId);
3270
3315
  }
3271
3316
 
3272
- // Clicking an inline-embedded image in a message bubble opens it in the file
3273
- // viewer modal (zoom + Download + Copy), the same viewer the repo browser
3274
- // uses. Attachment thumbnails keep their own handler; video/audio keep their
3275
- // native controls untouched.
3276
- function handleEmbeddedImageClick(event) {
3277
- const img = event.target.closest?.('.msg-bubble img');
3278
- if (!img || img.closest('.msg-attachment')) return;
3279
- if (IS_SHARED_VIEW) return; // the file routes are cookie-authed; shared viewers would only see a failed load
3280
- const src = String(img.getAttribute('src') || '').trim();
3281
- if (!src) return;
3317
+ // A tap on a media attachment (its picture, chip or name) or on a picture
3318
+ // embedded in a bubble opens it in the file viewer, together with the other
3319
+ // media of the conversation to swipe through. Inline video/audio players in
3320
+ // bubbles keep their native controls untouched.
3321
+ function handleChatMediaClick(event) {
3322
+ const attachment = event.target.closest?.('.msg-attachment[data-media-url]');
3323
+ let node = null;
3324
+ if (attachment) {
3325
+ // The buttons under a generated image ("Edit this image") do their own work.
3326
+ if (event.target.closest?.('button')) return;
3327
+ if (!event.target.closest?.('img, .msg-attachment-video-chip, [data-media-open]')) return;
3328
+ node = attachment;
3329
+ } else {
3330
+ const img = event.target.closest?.('.msg-bubble img');
3331
+ if (!img || img.closest('.msg-attachment')) return;
3332
+ if (IS_SHARED_VIEW) return; // the file routes are cookie-authed; shared viewers would only see a failed load
3333
+ if (!String(img.getAttribute('src') || '').trim()) return;
3334
+ node = img;
3335
+ }
3282
3336
  event.preventDefault();
3283
3337
  event.stopPropagation();
3284
- const target = parseAppFileHref(src);
3285
- if (target?.kind === 'drive') {
3286
- void openDriveFilePreview(target.path);
3287
- return;
3288
- }
3289
- if (target?.kind === 'workspace') {
3290
- void openWorkspaceFilePreview(target.path);
3291
- return;
3338
+ const options = { includeEmbedded: !IS_SHARED_VIEW, parseAppFileHref };
3339
+ const gallery = chatMediaGallery(getMessagesElement(), node, options);
3340
+ const item = gallery ? gallery.items[gallery.index] : chatMediaItemFromNode(node, options);
3341
+ void openGalleryItem(item, gallery);
3342
+ }
3343
+
3344
+ // On a touch screen the corner row (Pin, Hide) is revealed by a tap on the
3345
+ // bubble. The tap leaves an emulated :hover behind, which the CSS also honours,
3346
+ // but Chromium drops that hover at the next scroll or layout change on some
3347
+ // platforms (seen on Linux), and the row went back to `pointer-events: none`
3348
+ // before it could be tapped. The class keeps the reveal until a tap lands
3349
+ // outside the bubble. A pointer with hover keeps the hover reveal alone.
3350
+ const REVEALED_BUBBLE_CLASS = 'msg-actions-revealed';
3351
+ function handleBubbleTapReveal(event) {
3352
+ const bubble = event.target?.closest?.('.msg-bubble') || null;
3353
+ for (const revealed of document.querySelectorAll(`.msg-bubble.${REVEALED_BUBBLE_CLASS}`)) {
3354
+ if (revealed !== bubble) revealed.classList.remove(REVEALED_BUBBLE_CLASS);
3292
3355
  }
3293
- // data: thumbnails and external http(s) images: show them directly.
3294
- const dataMime = /^data:(image\/[a-z0-9.+-]+)/i.exec(src)?.[1] || '';
3295
- openUploadedAttachmentViewer(img.getAttribute('alt') || 'image', src, dataMime || 'image/*');
3356
+ if (!bubble || IS_SHARED_VIEW) return;
3357
+ if (!window.matchMedia?.('(hover: none)')?.matches) return;
3358
+ bubble.classList.add(REVEALED_BUBBLE_CLASS);
3296
3359
  }
3297
3360
 
3298
3361
  export function initBubbleActionHandlers() {
@@ -3300,7 +3363,9 @@ export function initBubbleActionHandlers() {
3300
3363
  if (!messagesEl) return;
3301
3364
  messagesEl.addEventListener('click', handleBubbleActionClick);
3302
3365
  messagesEl.addEventListener('keydown', handleBubbleActionKeydown);
3303
- messagesEl.addEventListener('click', handleEmbeddedImageClick);
3366
+ messagesEl.addEventListener('click', handleChatMediaClick);
3367
+ // On the document: a tap outside the transcript clears the reveal too.
3368
+ document.addEventListener('click', handleBubbleTapReveal);
3304
3369
  initThinkingFollow({ getCurrentConversationId: () => currentConvId });
3305
3370
  }
3306
3371
 
@@ -3412,6 +3477,20 @@ function buildMessageSnapshotKey(messages = [], meta = {}) {
3412
3477
  });
3413
3478
  }
3414
3479
 
3480
+ // Counts jumps into the history (a search result, a pinned message). A reload
3481
+ // of the END of the conversation that was asked for before a jump and is
3482
+ // answered after it would put the end back over the window just jumped to.
3483
+ // Only jumps count: an ordinary reload must still be able to correct another.
3484
+ let transcriptJumpEpoch = 0;
3485
+
3486
+ export function getTranscriptJumpEpoch() {
3487
+ return transcriptJumpEpoch;
3488
+ }
3489
+
3490
+ export function noteTranscriptJump() {
3491
+ transcriptJumpEpoch += 1;
3492
+ }
3493
+
3415
3494
  export function renderMessages(msgs, scroll = true, meta = {}) {
3416
3495
  const el = getMessagesElement();
3417
3496
  if (!el) return false;
@@ -3590,12 +3669,18 @@ export async function loadNewerConversationMessages() {
3590
3669
  await conversationFutureLoader.loadMore();
3591
3670
  }
3592
3671
 
3672
+ // False while a window in the middle of the history is on the page (a search
3673
+ // result or a pinned message was jumped to) and newer messages are unloaded.
3674
+ export function isConversationWindowAtTail() {
3675
+ return !conversationHistoryState.hasMoreNewer;
3676
+ }
3677
+
3593
3678
  export function focusConversationMessageById(messageId, { behavior = 'smooth', block = 'center' } = {}) {
3594
3679
  const id = String(messageId || '').trim();
3595
3680
  if (!id) return false;
3596
3681
  const el = getMessagesElement();
3597
3682
  if (!el) return false;
3598
- const target = el.querySelector(`[data-message-id="${id}"]`);
3683
+ const target = el.querySelector(`.msg[data-message-id="${CSS.escape(id)}"]`);
3599
3684
  if (!target) return false;
3600
3685
  target.scrollIntoView({ behavior, block, inline: 'nearest' });
3601
3686
  target.classList.add('msg-search-target');
@@ -57,7 +57,10 @@ export function installExternalLinkPolicy({ documentRef = document, onFallback =
57
57
  documentRef.addEventListener('click', (event) => {
58
58
  const target = event.target instanceof Element ? event.target : event.target?.parentElement;
59
59
  const anchor = target?.closest?.('a[href]');
60
- if (!anchor || isInAppLink(anchor)) return;
60
+ // A download link is left to the browser: opened in a new tab instead, it
61
+ // lost its `download` name, and a phone showed or saved the file under
62
+ // the plain name, next to (or instead of) an older copy.
63
+ if (!anchor || anchor.hasAttribute('download') || isInAppLink(anchor)) return;
61
64
  secureExternalLink(anchor, documentRef.baseURI, onFallback);
62
65
  if (!isExternalNavigationHref(anchor.getAttribute('href'), documentRef.baseURI)) return;
63
66
  if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
@@ -0,0 +1,58 @@
1
+ // A download link has to change when the file behind it changes. The address
2
+ // and the name a file is saved under both carry the file's version, so nothing
3
+ // between the relay and the Downloads folder can hand back an older copy:
4
+ // not a download manager or a proxy keyed on the URL, and not a phone that
5
+ // keeps `name.pdf`, `name (1).pdf`, `name (2).pdf` side by side and opens the
6
+ // first.
7
+
8
+ export function appendUrlParam(url, key, value) {
9
+ const base = String(url || '');
10
+ const text = String(value ?? '');
11
+ if (!base || !text) return base;
12
+ return `${base}${base.includes('?') ? '&' : '?'}${key}=${encodeURIComponent(text)}`;
13
+ }
14
+
15
+ /** `href` with the file's version token (`v=…`) appended; unchanged when there is none or it has one. */
16
+ export function versionedFileHref(href, version) {
17
+ const base = String(href || '');
18
+ if (/[?&]v=/.test(base)) return base;
19
+ return appendUrlParam(base, 'v', version);
20
+ }
21
+
22
+ function twoDigits(value) {
23
+ return String(value).padStart(2, '0');
24
+ }
25
+
26
+ /**
27
+ * The name to save a download under: the file's own name with the time it was
28
+ * last changed, `report.pdf` → `report (1005-0719).pdf` (month, day, hour,
29
+ * minute, local time). Two versions of a file get two names, and the name
30
+ * says which one is open. Without a known time the name stays as it is.
31
+ */
32
+ export function stampedDownloadName(name, mtimeMs) {
33
+ const plain = String(name || '').trim();
34
+ const time = Number(mtimeMs);
35
+ if (!plain || !Number.isFinite(time) || time <= 0) return plain;
36
+ const date = new Date(time);
37
+ if (Number.isNaN(date.getTime())) return plain;
38
+ const stamp = `${twoDigits(date.getMonth() + 1)}${twoDigits(date.getDate())}-${twoDigits(date.getHours())}${twoDigits(date.getMinutes())}`;
39
+ const dot = plain.lastIndexOf('.');
40
+ // A leading dot is part of the name (`.env`), not the start of an extension.
41
+ if (dot <= 0) return `${plain} (${stamp})`;
42
+ return `${plain.slice(0, dot)} (${stamp})${plain.slice(dot)}`;
43
+ }
44
+
45
+ /**
46
+ * The download link of a previewed file: versioned address, and the stamped
47
+ * name both as the link's `download` name and as `as=…`, which the relay puts
48
+ * into the `Content-Disposition` header (a browser prefers that header over
49
+ * the link's own name).
50
+ */
51
+ export function buildDownloadLink({ href, name, version, mtimeMs } = {}) {
52
+ const downloadName = stampedDownloadName(name, mtimeMs);
53
+ let url = versionedFileHref(href, version);
54
+ if (url && downloadName && downloadName !== String(name || '').trim()) {
55
+ url = appendUrlParam(url, 'as', downloadName);
56
+ }
57
+ return { href: url, downloadName };
58
+ }
@@ -42,9 +42,10 @@ import {
42
42
  loadGrokSettings,
43
43
  loadOpenAISettings,
44
44
  } from './api-client.js';
45
- import { renderMessages, restoreInFlightThinking, focusConversationMessageById, flushConversationDraft, hydrateConversationDraft, beginConversationDraftSwitch, openStopTurnConfirmationForConversation, conversationHasActiveTurn } from './conversation-view.js';
45
+ import { renderMessages, restoreInFlightThinking, focusConversationMessageById, noteTranscriptJump, flushConversationDraft, hydrateConversationDraft, beginConversationDraftSwitch, openStopTurnConfirmationForConversation, conversationHasActiveTurn } from './conversation-view.js';
46
46
  import { MENU_SEPARATOR, bindLongPress, closeContextMenu, openContextMenu } from './context-menu.mjs';
47
47
  import { setBackgroundTasksConversation, setConversationBackgroundTasks } from './background-tasks-view.mjs';
48
+ import { setConversationPins, setPinsConversation } from './pinned-messages-view.mjs';
48
49
  import { setUsageLimitConversation } from './usage-limit-ui.js';
49
50
  import { mergeConversationPreviews } from './preview-cards.mjs';
50
51
  import { loadRelayQuestions, getPendingQuestionCountsByConversation } from './ask-user-view.js';
@@ -428,10 +429,14 @@ export function renderConvList() {
428
429
  scheduleConversationListBoundaryCheck();
429
430
  }
430
431
 
432
+ // keepTranscript: the page shows a window this payload must not replace (the
433
+ // user jumped into the history; the payload is the end of the conversation).
434
+ // Everything but the messages still applies.
431
435
  export function applyLoadedConversationState(id, response, {
432
436
  restoreScroll = false,
433
437
  savedScrollTop = null,
434
438
  followLiveUpdates = !restoreScroll,
439
+ keepTranscript = false,
435
440
  } = {}) {
436
441
  if (!response) {
437
442
  setRepoBrowserSessionInfo('', '');
@@ -490,7 +495,13 @@ export function applyLoadedConversationState(id, response, {
490
495
  // live poll, and the bare loadRepoBrowserTree path resets loaded folders and
491
496
  // deep selections. The end-of-turn message_status handler refreshes the tree
492
497
  // through the restoring path instead.
493
- const didRenderMessages = renderMessages(response.messages, !restoreScroll, response);
498
+ // Before the messages: their bubbles take Pin / Unpin from this list.
499
+ // Absent from an older relay's payload: keep what the socket said.
500
+ if ('pins' in response) setConversationPins(id, response.pins, response.pinsRevision);
501
+ setPinsConversation(id);
502
+ const didRenderMessages = keepTranscript
503
+ ? false
504
+ : renderMessages(response.messages, !restoreScroll, response);
494
505
  hydrateConversationDraft(id, {
495
506
  draftText: response.draftText,
496
507
  draftAttachments: response.draftAttachments,
@@ -605,6 +616,9 @@ export async function openConversation(id, options = {}) {
605
616
  return;
606
617
  }
607
618
  if (r) {
619
+ // Counted when the window is put on the page, not when it is asked for:
620
+ // a reload of the end that is still on its way must see the change.
621
+ if (forceFreshWindow) noteTranscriptJump();
608
622
  applyLoadedConversationState(id, r, { restoreScroll, savedScrollTop });
609
623
  if (focusMessageId) {
610
624
  requestAnimationFrame(() => {