@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.
- package/CHANGELOG.md +344 -0
- package/README.md +89 -40
- package/bin/oar.js +1023 -135
- package/package.json +4 -5
- package/server/README.md +20 -1
- package/server/claude-worker/claude-sdk-adapter.mjs +0 -11
- package/server/claude-worker/claude-session-process.mjs +196 -28
- package/server/claude-worker/sdk-message-normalizer.mjs +28 -0
- package/server/db-schema.mjs +8 -0
- package/server/migrations/0008-message-pins.mjs +85 -0
- package/server/public/app/activity-replay-state.mjs +68 -5
- package/server/public/app/api-client.js +36 -2
- package/server/public/app/attachments-view.js +508 -125
- package/server/public/app/back-guard.mjs +58 -0
- package/server/public/app/bootstrap.js +170 -59
- package/server/public/app/context-usage-view.mjs +127 -8
- package/server/public/app/conversation-view.js +172 -32
- package/server/public/app/external-link-policy.mjs +4 -1
- package/server/public/app/file-version.mjs +58 -0
- package/server/public/app/journal-view.js +16 -2
- package/server/public/app/pinned-messages-view.mjs +253 -0
- package/server/public/app/pwa-install.js +6 -1
- package/server/public/app/relay-dot-state.mjs +8 -1
- package/server/public/app/router.js +8 -2
- package/server/public/app/scrollbar-hover.mjs +78 -0
- package/server/public/app/settings-modal.js +4 -1
- package/server/public/app/socket-handlers.js +8 -1
- package/server/public/app/store.js +32 -0
- package/server/public/app/transcript-separators.mjs +48 -11
- package/server/public/app/viewer-chat-gallery.mjs +59 -0
- package/server/public/app/viewer-controls-state.mjs +59 -0
- package/server/public/app/viewer-gallery.mjs +46 -0
- package/server/public/app/viewer-gestures.mjs +105 -0
- package/server/public/app/viewer-history.mjs +43 -0
- package/server/public/app/viewer-tap.mjs +112 -0
- package/server/public/app-icon-192.png +0 -0
- package/server/public/app-icon-512.png +0 -0
- package/server/public/app-icon-light-192.png +0 -0
- package/server/public/app-icon-light-512.png +0 -0
- package/server/public/app-icon-light-maskable-512.png +0 -0
- package/server/public/app-icon-light.svg +4 -0
- package/server/public/app-icon-maskable-512.png +0 -0
- package/server/public/app-icon.svg +3 -3
- package/server/public/favicon.ico +0 -0
- package/server/public/index.html +487 -67
- package/server/public/manifest.webmanifest +9 -3
- package/server/public/sw.js +6 -1
- package/server/repositories/message-pin-statements.mjs +39 -0
- package/server/repositories/message-repository.mjs +5 -0
- package/server/routes/messages-routes.mjs +36 -62
- package/server/routes/sessions-routes.mjs +107 -26
- package/server/server-runtime.mjs +39 -8
- package/server/services/claude-context-usage.mjs +51 -3
- package/server/services/cloudflared-tunnel-service.mjs +139 -41
- package/server/services/file-serving.mjs +155 -0
- package/server/services/message-pin-list.mjs +52 -0
- package/server/services/oar-cli-helpers.mjs +205 -4
- package/server/services/oar-cli-menu.mjs +240 -0
- package/server/services/oar-cli-wsl.mjs +129 -0
- package/server/services/pwa-manifest-theme.mjs +30 -0
- package/server/services/safe-served-content.mjs +61 -11
- package/server/services/session-history-refresh-service.mjs +24 -0
- package/server/services/session-worker-registry-service.mjs +18 -1
- 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,
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
3218
|
-
//
|
|
3219
|
-
//
|
|
3220
|
-
// native controls untouched.
|
|
3221
|
-
function
|
|
3222
|
-
const
|
|
3223
|
-
|
|
3224
|
-
if (
|
|
3225
|
-
|
|
3226
|
-
|
|
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
|
|
3230
|
-
|
|
3231
|
-
|
|
3232
|
-
|
|
3233
|
-
|
|
3234
|
-
|
|
3235
|
-
|
|
3236
|
-
|
|
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
|
-
|
|
3239
|
-
|
|
3240
|
-
|
|
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',
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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(() => {
|