@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
@@ -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';
@@ -88,8 +90,14 @@ import {
88
90
  normalizeRelayActivityEntry,
89
91
  promotedCompactBoundaryEntry,
90
92
  relayActivityEntryText,
93
+ visibleActivityEntries,
91
94
  } from './activity-replay-state.mjs';
92
- import { SEPARATOR_CLASS, syncSeparatorRail, syncTranscriptSeparators } from './transcript-separators.mjs';
95
+ import {
96
+ COMPACT_PENDING_STAMP,
97
+ SEPARATOR_CLASS,
98
+ syncSeparatorRail,
99
+ syncTranscriptSeparators,
100
+ } from './transcript-separators.mjs';
93
101
  import { ABSORBED_MSG_CLASS, STEER_MARKER_CLASS_BY_KIND, syncSteeredTurnMerge } from './steered-turn-merge.mjs';
94
102
  import { resolveSubagentFold, subagentFoldSummary, toggledSubagentFoldChoice } from './subagent-fold.mjs';
95
103
  import { deriveComposerControlState, hasComposerDraft, hasUploadingAttachments } from './composer-control-state.mjs';
@@ -1338,12 +1346,14 @@ function createMessageNode(msg, msgId = null, force = false) {
1338
1346
  // break row immediately above this message (syncTranscriptSeparators reads
1339
1347
  // the stamp), so it must not also show up inside the bubble's activity list.
1340
1348
  // Only the promoted (last) boundary is hidden — see renderActivityMarkup.
1349
+ // A rendered row is a finished message, so a compaction start without an
1350
+ // end draws no line here (the live bubble owns the running one).
1341
1351
  const promotedBoundaryEntry = promotedCompactBoundaryEntry(activities);
1342
1352
  const compactBoundary = compactBoundaryFromActivities(activities);
1343
1353
  if (compactBoundary) {
1344
1354
  div.dataset.compactBoundary = `${compactBoundary.preTokens ?? ''}|${compactBoundary.postTokens ?? ''}`;
1345
1355
  }
1346
- if (activities.some((item) => item !== promotedBoundaryEntry)) div.classList.add('msg-with-activity');
1356
+ if (visibleActivityEntries(activities, promotedBoundaryEntry).length) div.classList.add('msg-with-activity');
1347
1357
  const thoughts = Array.isArray(msg.thoughts) ? msg.thoughts.filter((t) => t && String(t.text || '').trim()) : [];
1348
1358
  const attachmentHtml = attachments.length ? renderAttachmentMarkup(attachments, { messageId: msgId }) : '';
1349
1359
  const subagentRuns = Array.isArray(msg.subagentRuns) ? msg.subagentRuns.filter(Boolean) : [];
@@ -1385,8 +1395,13 @@ function createMessageNode(msg, msgId = null, force = false) {
1385
1395
  const belongsToActiveTurn = !!activeTurnMessageId
1386
1396
  && (activeTurnMessageId === msgId || activeTurnMessageId === sourceMessageId);
1387
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);
1388
1402
  const shareVisibilityActionHtml = canToggleShareVisibility
1389
1403
  ? `<div class="msg-share-visibility">
1404
+ ${buildMessagePinControlsHtml(msgId, pinned)}
1390
1405
  ${hiddenFromShares ? '<span class="msg-hidden-label">Hidden from shared viewers</span>' : ''}
1391
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>
1392
1407
  </div>`
@@ -1479,7 +1494,15 @@ function insertMessageNode(node, insertAfterId = null) {
1479
1494
  const anchorId = String(insertAfterId || '').trim();
1480
1495
  const anchor = anchorId ? el.querySelector(`[data-message-id="${anchorId}"]`) : null;
1481
1496
  if (anchor && anchor.parentNode === el) {
1482
- const next = anchor.nextSibling;
1497
+ let next = anchor.nextSibling;
1498
+ // The line of a compaction that ran in this turn sits between the user
1499
+ // message and its live bubble; the reply lands under it, so the
1500
+ // separator pass relabels that line instead of replacing it.
1501
+ if (next?.nodeType === 1
1502
+ && next.classList?.contains(SEPARATOR_CLASS)
1503
+ && next.dataset?.separatorKey === `compact:${anchorId}`) {
1504
+ next = next.nextSibling;
1505
+ }
1483
1506
  if (next) el.insertBefore(node, next);
1484
1507
  else el.appendChild(node);
1485
1508
  } else {
@@ -1604,11 +1627,12 @@ export function renderActivityMarkup(activities, promotedBoundaryEntry = undefin
1604
1627
  // ones stay visible here as prose rather than disappearing from the
1605
1628
  // transcript altogether (one break row per message is all the separator
1606
1629
  // planner can place).
1630
+ // A compaction's start and cancel steps are never prose either.
1607
1631
  const list = Array.isArray(activities) ? activities : [];
1608
1632
  const promoted = promotedBoundaryEntry === undefined
1609
1633
  ? promotedCompactBoundaryEntry(list)
1610
1634
  : promotedBoundaryEntry;
1611
- const visible = list.filter((item) => item !== promoted);
1635
+ const visible = visibleActivityEntries(list, promoted);
1612
1636
  const progress = visible.filter((item) => relayActivityEntryText(item).startsWith('● '));
1613
1637
  const tools = visible.filter((item) => !relayActivityEntryText(item).startsWith('● '));
1614
1638
  const progressHtml = progress.length
@@ -1711,10 +1735,16 @@ function anchorThinkingBubble(div, messageId) {
1711
1735
  const id = String(messageId || '').trim();
1712
1736
  const target = id ? el.querySelector(`[data-message-id="${id}"]`) : null;
1713
1737
  if (target && target.parentNode === el) {
1714
- if (target.nextSibling === div) return;
1738
+ // The bubble's own compaction line sits between the two.
1739
+ let follower = target.nextSibling;
1740
+ while (follower && follower !== div && follower.classList?.contains('transcript-separator')) {
1741
+ follower = follower.nextSibling;
1742
+ }
1743
+ if (follower === div) return;
1715
1744
  const next = target.nextSibling;
1716
1745
  if (next) el.insertBefore(div, next);
1717
1746
  else el.appendChild(div);
1747
+ if (div.dataset.compactBoundary) syncSeparatorsNow();
1718
1748
  } else if (div.parentNode !== el) {
1719
1749
  el.appendChild(div);
1720
1750
  }
@@ -1771,11 +1801,13 @@ export function showThinking(messageId = null, autoScroll = true) {
1771
1801
  <div class="thinking-thoughts-list"></div>
1772
1802
  </details>
1773
1803
  <div id="thinking-stream" class="thinking-stream" hidden></div>
1804
+ <div class="thinking-compacting-note" hidden>Compacting the conversation…</div>
1774
1805
  <div class="dots"><span></span><span></span><span></span></div>
1775
1806
  <div id="thinking-activity" class="thinking-activity"></div>
1776
1807
  <div class="subagent-bubbles-container" data-subagent-bubbles-root="1"></div>
1777
1808
  </div>`;
1778
1809
  anchorThinkingBubble(div, nextMessageId);
1810
+ syncThinkingCompaction(div);
1779
1811
  renderThinkingThoughts();
1780
1812
  renderThinkingStream();
1781
1813
  // A board the turn has posted already lives in its live bubble.
@@ -1788,8 +1820,33 @@ export function showThinking(messageId = null, autoScroll = true) {
1788
1820
  export function removeThinking() {
1789
1821
  thinkingMessageId = null;
1790
1822
  lastInFlightSnapshotKey = '';
1823
+ const wasCompacting = !!document.getElementById('thinking-indicator')?.dataset?.compactBoundary;
1791
1824
  unobserveThinkingBubble();
1792
1825
  document.getElementById('thinking-indicator')?.remove();
1826
+ // The bubble's compaction line goes with it, but only after the
1827
+ // caller's same-tick work: a reply appended right after this call takes
1828
+ // the line over (relabelled in place) instead of it being dropped first.
1829
+ if (wasCompacting) queueMicrotask(syncSeparatorsNow);
1830
+ }
1831
+
1832
+ // The live turn's compaction line: the bubble stamps the turn's compaction
1833
+ // for the separator pass — "Compacting context…" while the last compaction
1834
+ // entry is a start without an end, the final label once the boundary is in —
1835
+ // so the line sits above the bubble and the reply takes it over unchanged.
1836
+ // The bubble's own note shows only while it runs.
1837
+ function syncThinkingCompaction(bubble = document.getElementById('thinking-indicator')) {
1838
+ if (!bubble) return;
1839
+ const items = thinkingMessageId ? (relayActivities.get(thinkingMessageId) || []) : [];
1840
+ const boundary = compactBoundaryFromActivities(items, { processing: true });
1841
+ const note = bubble.querySelector('.thinking-compacting-note');
1842
+ if (note) note.hidden = !boundary?.pending;
1843
+ const stamp = !boundary
1844
+ ? ''
1845
+ : (boundary.pending ? COMPACT_PENDING_STAMP : `${boundary.preTokens ?? ''}|${boundary.postTokens ?? ''}`);
1846
+ if ((bubble.dataset.compactBoundary || '') === stamp) return;
1847
+ if (stamp) bubble.dataset.compactBoundary = stamp;
1848
+ else delete bubble.dataset.compactBoundary;
1849
+ if (bubble.parentNode) syncSeparatorsNow();
1793
1850
  }
1794
1851
 
1795
1852
  // Stop is one tap away on a sticky header, so a stray touch must not kill a
@@ -1955,6 +2012,7 @@ export function renderThinkingActivities() {
1955
2012
  if (!selectionIntersectsNode(box)) {
1956
2013
  patchActivityList(box, mainExpected, 'thinking-activity-item');
1957
2014
  }
2015
+ syncThinkingCompaction();
1958
2016
  for (const [subagentRunId, expected] of bySubagentRun) {
1959
2017
  const bubble = ensureSubagentBubble(subagentRunId);
1960
2018
  const runBox = bubble?.querySelector('.subagent-activity');
@@ -2060,8 +2118,12 @@ export function appendThinkingActivity(item, subagentRunId = null, autoScroll =
2060
2118
  );
2061
2119
  if (!entry) return;
2062
2120
  // Same rule as renderThinkingActivities: a boundary becomes a break row on
2063
- // the persisted message, so it never shows as live prose.
2064
- if (isCompactBoundaryActivityEntry(entry)) return;
2121
+ // the persisted message, so it never shows as live prose. Every
2122
+ // compaction step does move the live turn's compaction line, though.
2123
+ if (isCompactBoundaryActivityEntry(entry)) {
2124
+ syncThinkingCompaction();
2125
+ return;
2126
+ }
2065
2127
  const decorated = decorateActivityText(entry.text);
2066
2128
 
2067
2129
  if (subagentRunId) {
@@ -3079,6 +3141,38 @@ async function cancelSubagentByRunId(conversationId, subagentRunId) {
3079
3141
  updateSubagentStopButton(targetSubagentRunId, false);
3080
3142
  }
3081
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
+
3082
3176
  async function toggleMessageShareVisibility(conversationId, messageId, hiddenFromShares) {
3083
3177
  const conversationKey = String(conversationId || '').trim();
3084
3178
  const targetMessageId = String(messageId || '').trim();
@@ -3137,7 +3231,7 @@ function runRelayErrorCta(cta) {
3137
3231
  }
3138
3232
 
3139
3233
  function handleBubbleActionClick(event) {
3140
- 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');
3141
3235
  if (!btn) return;
3142
3236
  const action = btn.dataset.action;
3143
3237
  const messageId = btn.dataset.messageId;
@@ -3159,6 +3253,12 @@ function handleBubbleActionClick(event) {
3159
3253
  );
3160
3254
  }
3161
3255
 
3256
+ if (action === 'toggle-pin' && messageId) {
3257
+ event.preventDefault();
3258
+ event.stopPropagation();
3259
+ void toggleMessagePin(currentConvId, messageId, btn.dataset.pinned === 'true');
3260
+ }
3261
+
3162
3262
  if (action === 'stop-turn' && messageId) {
3163
3263
  event.preventDefault();
3164
3264
  event.stopPropagation();
@@ -3214,30 +3314,48 @@ function handleBubbleActionKeydown(event) {
3214
3314
  toggleSubagentFold(header.dataset.subagentRunId);
3215
3315
  }
3216
3316
 
3217
- // Clicking an inline-embedded image in a message bubble opens it in the file
3218
- // viewer modal (zoom + Download + Copy), the same viewer the repo browser
3219
- // uses. Attachment thumbnails keep their own handler; video/audio keep their
3220
- // native controls untouched.
3221
- function handleEmbeddedImageClick(event) {
3222
- const img = event.target.closest?.('.msg-bubble img');
3223
- if (!img || img.closest('.msg-attachment')) return;
3224
- if (IS_SHARED_VIEW) return; // the file routes are cookie-authed; shared viewers would only see a failed load
3225
- const src = String(img.getAttribute('src') || '').trim();
3226
- 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
+ }
3227
3336
  event.preventDefault();
3228
3337
  event.stopPropagation();
3229
- const target = parseAppFileHref(src);
3230
- if (target?.kind === 'drive') {
3231
- void openDriveFilePreview(target.path);
3232
- return;
3233
- }
3234
- if (target?.kind === 'workspace') {
3235
- void openWorkspaceFilePreview(target.path);
3236
- 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);
3237
3355
  }
3238
- // data: thumbnails and external http(s) images: show them directly.
3239
- const dataMime = /^data:(image\/[a-z0-9.+-]+)/i.exec(src)?.[1] || '';
3240
- 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);
3241
3359
  }
3242
3360
 
3243
3361
  export function initBubbleActionHandlers() {
@@ -3245,7 +3363,9 @@ export function initBubbleActionHandlers() {
3245
3363
  if (!messagesEl) return;
3246
3364
  messagesEl.addEventListener('click', handleBubbleActionClick);
3247
3365
  messagesEl.addEventListener('keydown', handleBubbleActionKeydown);
3248
- 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);
3249
3369
  initThinkingFollow({ getCurrentConversationId: () => currentConvId });
3250
3370
  }
3251
3371
 
@@ -3357,6 +3477,20 @@ function buildMessageSnapshotKey(messages = [], meta = {}) {
3357
3477
  });
3358
3478
  }
3359
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
+
3360
3494
  export function renderMessages(msgs, scroll = true, meta = {}) {
3361
3495
  const el = getMessagesElement();
3362
3496
  if (!el) return false;
@@ -3535,12 +3669,18 @@ export async function loadNewerConversationMessages() {
3535
3669
  await conversationFutureLoader.loadMore();
3536
3670
  }
3537
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
+
3538
3678
  export function focusConversationMessageById(messageId, { behavior = 'smooth', block = 'center' } = {}) {
3539
3679
  const id = String(messageId || '').trim();
3540
3680
  if (!id) return false;
3541
3681
  const el = getMessagesElement();
3542
3682
  if (!el) return false;
3543
- const target = el.querySelector(`[data-message-id="${id}"]`);
3683
+ const target = el.querySelector(`.msg[data-message-id="${CSS.escape(id)}"]`);
3544
3684
  if (!target) return false;
3545
3685
  target.scrollIntoView({ behavior, block, inline: 'nearest' });
3546
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(() => {