@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.
- package/CHANGELOG.md +231 -0
- package/README.md +53 -32
- package/bin/oar.js +814 -228
- package/package.json +4 -5
- package/server/README.md +20 -1
- package/server/db-schema.mjs +8 -0
- package/server/migrations/0008-message-pins.mjs +85 -0
- package/server/public/app/api-client.js +30 -0
- package/server/public/app/attachments-view.js +508 -125
- package/server/public/app/back-guard.mjs +58 -0
- package/server/public/app/bootstrap.js +46 -2
- package/server/public/app/conversation-view.js +110 -25
- 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 +1 -1
- package/server/public/app/relay-dot-state.mjs +8 -1
- package/server/public/app/router.js +8 -2
- package/server/public/app/socket-handlers.js +6 -0
- package/server/public/app/store.js +6 -0
- 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/index.html +304 -54
- package/server/repositories/message-pin-statements.mjs +39 -0
- package/server/repositories/message-repository.mjs +5 -0
- package/server/routes/messages-routes.mjs +33 -61
- package/server/routes/sessions-routes.mjs +92 -24
- package/server/server-runtime.mjs +29 -0
- 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 +139 -4
- package/server/services/oar-cli-menu.mjs +240 -0
- package/server/services/oar-cli-wsl.mjs +129 -0
- package/server/services/safe-served-content.mjs +61 -11
- 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,
|
|
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
|
-
//
|
|
3273
|
-
//
|
|
3274
|
-
//
|
|
3275
|
-
// native controls untouched.
|
|
3276
|
-
function
|
|
3277
|
-
const
|
|
3278
|
-
|
|
3279
|
-
if (
|
|
3280
|
-
|
|
3281
|
-
|
|
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
|
|
3285
|
-
|
|
3286
|
-
|
|
3287
|
-
|
|
3288
|
-
|
|
3289
|
-
|
|
3290
|
-
|
|
3291
|
-
|
|
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
|
-
|
|
3294
|
-
|
|
3295
|
-
|
|
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',
|
|
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(
|
|
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
|
-
|
|
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(() => {
|