@oar-sh/oar 0.9.11 → 0.9.12
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 +126 -0
- package/README.md +10 -10
- package/package.json +1 -1
- package/server/README.md +17 -3
- package/server/claude-worker/claude-remote-relay-tool.mjs +2 -0
- package/server/claude-worker/claude-sdk-adapter.mjs +57 -4
- package/server/claude-worker/claude-session-process.mjs +128 -0
- package/server/claude-worker/claude-session-worker.mjs +17 -0
- package/server/claude-worker/task-output-discovery.mjs +126 -0
- package/server/copilot-worker/copilot-sdk-session-process.mjs +159 -3
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +2 -0
- package/server/copilot-worker/copilot-shell-output-mirror.mjs +94 -0
- package/server/copilot-worker/copilot-task-settle.mjs +96 -0
- package/server/db-schema.mjs +9 -0
- package/server/grok-worker/acp-host-services.mjs +42 -3
- package/server/grok-worker/grok-background-tasks.mjs +241 -0
- package/server/grok-worker/grok-sdk-adapter.mjs +3 -1
- package/server/grok-worker/grok-session-worker.mjs +33 -1
- package/server/grok-worker/grok-turn-runner.mjs +11 -0
- package/server/migrations/0017-conversation-history-indexes.mjs +89 -0
- package/server/public/app/api-client.js +13 -0
- package/server/public/app/background-tasks-view.mjs +285 -16
- package/server/public/app/bootstrap.js +95 -5
- package/server/public/app/claude-background-command-limit-options.mjs +21 -0
- package/server/public/app/conversation-payload-cache.mjs +357 -0
- package/server/public/app/conversation-turn-state.mjs +1 -1
- package/server/public/app/conversation-view.js +433 -3
- package/server/public/app/conversation-window-retention.mjs +96 -0
- package/server/public/app/journal-view.js +252 -66
- package/server/public/app/relay-prompt-sanitizer.mjs +11 -4
- package/server/public/app/settings-modal.js +110 -2
- package/server/public/app/socket-handlers.js +59 -7
- package/server/public/app/store.js +25 -0
- package/server/public/app/subagent-viewer.mjs +7 -1
- package/server/public/app/task-console-render.mjs +237 -0
- package/server/public/app/task-console-watch.mjs +42 -0
- package/server/public/app/task-console.mjs +630 -0
- package/server/public/app/task-output-text.mjs +234 -0
- package/server/public/app/transcript-island.mjs +73 -0
- package/server/public/app/transcript-jump-bottom-view.mjs +38 -8
- package/server/public/app/transcript-jump-bottom.mjs +48 -11
- package/server/public/app/transcript-reading-anchor.mjs +256 -0
- package/server/public/app/transcript-reload-freshness.mjs +17 -1
- package/server/public/app/transcript-viewport-anchor.mjs +20 -1
- package/server/public/index.html +239 -6
- package/server/relay-tools.md +1 -0
- package/server/repositories/message-repository.mjs +8 -0
- package/server/repositories/question-repository.mjs +4 -0
- package/server/routes/messages-routes.mjs +249 -3
- package/server/routes/sessions-routes.mjs +133 -63
- package/server/server-runtime.mjs +77 -0
- package/server/services/background-task-store.mjs +240 -3
- package/server/services/cli-process-runner.mjs +4 -21
- package/server/services/conversation-history-page.mjs +199 -0
- package/server/services/relay-prompt-sanitizer.mjs +11 -4
- package/server/services/remote-relay-dispatcher.mjs +130 -2
- package/server/services/session-activity-tracker.mjs +6 -2
- package/server/services/task-output-rooms.mjs +259 -0
- package/server/services/task-output-service.mjs +562 -0
- package/shared/claude-background-command-limit.mjs +83 -0
- package/shared/remote-relay-contract.mjs +32 -2
- package/shared/task-output-text.mjs +737 -0
- package/shared/worker-runtime/task-output-spool.mjs +181 -0
|
@@ -47,6 +47,8 @@ import {
|
|
|
47
47
|
openSummaryModal,
|
|
48
48
|
closeSummaryModal,
|
|
49
49
|
toggleSidebar,
|
|
50
|
+
hasPendingUserMessageForConversation,
|
|
51
|
+
isMessagesNearBottom,
|
|
50
52
|
} from './store.js';
|
|
51
53
|
import { getPendingQuestionCountsByConversation } from './ask-user-view.js';
|
|
52
54
|
import { sendMessage as sendMessageApi, cancelConversationTurn, cancelQueuedConversationTurn, cancelSubagentRun, compactConversation as compactConversationApi, scheduleContextUsageRefresh, loadConversation as loadConversationApi, loadSharedConversation, updateConversationDraft as updateConversationDraftApi, updateMessageShareVisibility, launchSessionWorker } from './api-client.js';
|
|
@@ -131,6 +133,7 @@ import {
|
|
|
131
133
|
shouldApplyIncomingDraftToComposer,
|
|
132
134
|
} from './conversation-draft-sync.mjs';
|
|
133
135
|
import { isChatInteractionHeld, selectionIntersectsNode } from './selection-guard.mjs';
|
|
136
|
+
import { captureReadingAnchor, readingAnchorContentTop, readingAnchorHoldStep, readingAnchorScrollDelta } from './transcript-reading-anchor.mjs';
|
|
134
137
|
import { captureReadingBlock, restoreReadingBlock } from './transcript-viewport-anchor.mjs';
|
|
135
138
|
import {
|
|
136
139
|
applyThinkingFollow,
|
|
@@ -142,10 +145,12 @@ import {
|
|
|
142
145
|
toggleThinkingFollowMode,
|
|
143
146
|
unobserveThinkingBubble,
|
|
144
147
|
} from './thinking-follow.js';
|
|
145
|
-
import { beginOwnSend, followsTranscriptEnd, nameOwnSendConversation, ownSendFollows, scrollAfterOwnSend } from './transcript-jump-bottom-view.mjs';
|
|
148
|
+
import { beginOwnSend, followsTranscriptEnd, jumpTranscriptToBottom, nameOwnSendConversation, ownSendFollows, refreshJumpButton, scrollAfterOwnSend } from './transcript-jump-bottom-view.mjs';
|
|
149
|
+
import { createHeldBackLiveRows, newerPageReachesEnd } from './transcript-island.mjs';
|
|
146
150
|
import { buildInFlightSnapshotKey } from './in-flight-snapshot.mjs';
|
|
147
151
|
import { computeStablePrefixLength, planListPatch } from './streaming-dom-patch.mjs';
|
|
148
152
|
import { prefixToolActivityEmoji } from './tool-activity-emoji.mjs';
|
|
153
|
+
import { conversationPayloadCache } from './conversation-payload-cache.mjs';
|
|
149
154
|
|
|
150
155
|
const CONVERSATION_HISTORY_PAGE_SIZE = 20;
|
|
151
156
|
const HISTORY_LOAD_MORE_ID = 'history-load-more';
|
|
@@ -239,6 +244,20 @@ let conversationHistoryState = {
|
|
|
239
244
|
loadingOlder: false,
|
|
240
245
|
loadingNewer: false,
|
|
241
246
|
};
|
|
247
|
+
// Live rows for a window that ends above the conversation's end: held back
|
|
248
|
+
// until the newer pages bring them (transcript-island.mjs).
|
|
249
|
+
const heldBackLiveRows = createHeldBackLiveRows();
|
|
250
|
+
|
|
251
|
+
// After every change of conversationHistoryState: a window that meets the
|
|
252
|
+
// end, or another conversation's, holds no rows back. The button's count
|
|
253
|
+
// changes without the page changing, so it is told.
|
|
254
|
+
function syncHeldBackLiveRows() {
|
|
255
|
+
const released = heldBackLiveRows.follow({
|
|
256
|
+
conversationId: conversationHistoryState.conversationId,
|
|
257
|
+
atTail: !conversationHistoryState.hasMoreNewer,
|
|
258
|
+
});
|
|
259
|
+
if (released) refreshJumpButton();
|
|
260
|
+
}
|
|
242
261
|
|
|
243
262
|
function renderImageEditTarget() {
|
|
244
263
|
const chip = document.getElementById('image-edit-target');
|
|
@@ -354,6 +373,8 @@ const conversationHistoryLoader = createInfiniteLoader({
|
|
|
354
373
|
const previousScrollTop = el.scrollTop;
|
|
355
374
|
const previousScrollHeight = el.scrollHeight;
|
|
356
375
|
const inserted = prependMessageNodes(page.items || []);
|
|
376
|
+
// The kept window grows with the page: a switch back shows these rows too.
|
|
377
|
+
if (!IS_SHARED_VIEW) conversationPayloadCache.addOlder(currentId, page.items || [], { hasMoreOlder: page.hasMore });
|
|
357
378
|
setConversationHistoryState({
|
|
358
379
|
conversationId: currentId,
|
|
359
380
|
hasMoreOlder: page.hasMore,
|
|
@@ -381,6 +402,10 @@ const conversationHistoryLoader = createInfiniteLoader({
|
|
|
381
402
|
if (!el || String(currentConvId || '').trim() !== currentId) return;
|
|
382
403
|
const nextScrollHeight = el.scrollHeight;
|
|
383
404
|
el.scrollTop = previousScrollTop + (nextScrollHeight - previousScrollHeight);
|
|
405
|
+
// The page's images have no height yet: the place read is held while
|
|
406
|
+
// they come in, as after a restore.
|
|
407
|
+
const place = captureConversationReadingAnchor();
|
|
408
|
+
if (place?.conversationId === currentId) holdReadingAnchorWhileMediaLoads(el, place.anchor);
|
|
384
409
|
void conversationHistoryLoader.handleBoundaryDistance(el.scrollTop);
|
|
385
410
|
});
|
|
386
411
|
},
|
|
@@ -427,6 +452,24 @@ const conversationFutureLoader = createInfiniteLoader({
|
|
|
427
452
|
const currentId = String(currentConvId || '').trim();
|
|
428
453
|
if (!currentId || conversationPaginationCursorIsStale(currentId)) return;
|
|
429
454
|
const ordered = sortConversationMessages(page.items || []);
|
|
455
|
+
// The last page may predate a row held back meanwhile: one more is asked
|
|
456
|
+
// for, from this page's newest row. The loader reads hasMore and the
|
|
457
|
+
// cursor off this page once it is applied.
|
|
458
|
+
const heldBackIds = heldBackLiveRows.ids(currentId);
|
|
459
|
+
if (!page.hasMore && heldBackIds.length && !newerPageReachesEnd({
|
|
460
|
+
pageHasMore: page.hasMore,
|
|
461
|
+
pageMessageIds: ordered.map((m) => m?.id),
|
|
462
|
+
heldBackIds,
|
|
463
|
+
renderedIds: renderedMessageIds(),
|
|
464
|
+
})) {
|
|
465
|
+
const newest = ordered[ordered.length - 1];
|
|
466
|
+
page.hasMore = true;
|
|
467
|
+
page.nextCursor = {
|
|
468
|
+
afterMessageId: String(newest?.id || '').trim() || null,
|
|
469
|
+
afterTimestamp: String(newest?.timestamp || '').trim() || null,
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
if (!IS_SHARED_VIEW) conversationPayloadCache.addNewer(currentId, ordered, { hasMoreNewer: page.hasMore });
|
|
430
473
|
let inserted = 0;
|
|
431
474
|
withSuspendedSeparatorSync(() => {
|
|
432
475
|
for (const m of ordered) {
|
|
@@ -448,6 +491,9 @@ const conversationFutureLoader = createInfiniteLoader({
|
|
|
448
491
|
loadingOlder: conversationHistoryState.loadingOlder,
|
|
449
492
|
loadingNewer: false,
|
|
450
493
|
});
|
|
494
|
+
// The window meets the end: a turn still running gets its live bubble,
|
|
495
|
+
// which was not drawn under the window while it ended above.
|
|
496
|
+
if (!page.hasMore) showLiveBubbleOfRunningTurn();
|
|
451
497
|
},
|
|
452
498
|
onError: (error, { mode }) => {
|
|
453
499
|
if (mode === 'load') {
|
|
@@ -460,6 +506,7 @@ const conversationFutureLoader = createInfiniteLoader({
|
|
|
460
506
|
hasMoreNewer: state.hasMore,
|
|
461
507
|
loadingNewer: state.isLoading,
|
|
462
508
|
};
|
|
509
|
+
syncHeldBackLiveRows();
|
|
463
510
|
},
|
|
464
511
|
});
|
|
465
512
|
|
|
@@ -1177,6 +1224,7 @@ function resetConversationHistoryState() {
|
|
|
1177
1224
|
conversationFutureLoader.reset({ hasMore: false, nextCursor: null });
|
|
1178
1225
|
saveConversationLoadedMessageCount(conversationId, 0);
|
|
1179
1226
|
syncHistoryLoadMoreControl();
|
|
1227
|
+
syncHeldBackLiveRows();
|
|
1180
1228
|
}
|
|
1181
1229
|
|
|
1182
1230
|
function setConversationHistoryState(next = {}) {
|
|
@@ -1197,6 +1245,7 @@ function setConversationHistoryState(next = {}) {
|
|
|
1197
1245
|
conversationHistoryState.loadedMessageCount,
|
|
1198
1246
|
);
|
|
1199
1247
|
syncHistoryLoadMoreControl();
|
|
1248
|
+
syncHeldBackLiveRows();
|
|
1200
1249
|
}
|
|
1201
1250
|
|
|
1202
1251
|
function getConversationHistoryCursor() {
|
|
@@ -1873,6 +1922,16 @@ export function showThinking(messageId = null, autoScroll = true) {
|
|
|
1873
1922
|
const nextMessageId = String(messageId || '').trim();
|
|
1874
1923
|
if (nextMessageId) thinkingMessageId = nextMessageId;
|
|
1875
1924
|
const existing = document.getElementById('thinking-indicator');
|
|
1925
|
+
// A window that ends above the conversation's end shows no live bubble:
|
|
1926
|
+
// under it, it would sit below rows that are not loaded. The turn's
|
|
1927
|
+
// thoughts, tools and text are kept all the same; the bubble is drawn once
|
|
1928
|
+
// the window meets the end.
|
|
1929
|
+
if (!isConversationWindowAtTail()) {
|
|
1930
|
+
// Through the same path as removeThinking, so the separators and the
|
|
1931
|
+
// agent folds no longer show the bubble either; the turn's id stays.
|
|
1932
|
+
if (existing) takeThinkingBubbleOff();
|
|
1933
|
+
return;
|
|
1934
|
+
}
|
|
1876
1935
|
if (existing
|
|
1877
1936
|
&& existing.dataset.conversationId === thinkingConversationKey()
|
|
1878
1937
|
&& (!nextMessageId || String(existing.dataset.messageId || '') === nextMessageId)) {
|
|
@@ -1933,6 +1992,14 @@ export function showThinking(messageId = null, autoScroll = true) {
|
|
|
1933
1992
|
export function removeThinking() {
|
|
1934
1993
|
thinkingMessageId = null;
|
|
1935
1994
|
lastInFlightSnapshotKey = '';
|
|
1995
|
+
takeThinkingBubbleOff();
|
|
1996
|
+
}
|
|
1997
|
+
|
|
1998
|
+
// The live bubble leaves the page, with what hangs on it (its compaction
|
|
1999
|
+
// line, the agent folds' "working"). The turn it shows is not forgotten:
|
|
2000
|
+
// removeThinking does that; a window that stops reaching the end only hides
|
|
2001
|
+
// the bubble until it meets the end again (showThinking).
|
|
2002
|
+
function takeThinkingBubbleOff() {
|
|
1936
2003
|
const hadBubble = !!document.getElementById('thinking-indicator');
|
|
1937
2004
|
const wasCompacting = !!document.getElementById('thinking-indicator')?.dataset?.compactBoundary;
|
|
1938
2005
|
unobserveThinkingBubble();
|
|
@@ -3121,9 +3188,12 @@ async function resendStoppedSteer(conversationId, markerId) {
|
|
|
3121
3188
|
showTransientRelayNotice('Select a reasoning effort after refreshing model metadata.');
|
|
3122
3189
|
return;
|
|
3123
3190
|
}
|
|
3191
|
+
// As a send from the composer (loadEndForOwnSend): the Resend goes to
|
|
3192
|
+
// the end.
|
|
3193
|
+
if (isCurrentConversation(conversationKey)) await loadEndForOwnSend();
|
|
3124
3194
|
trackPendingUserMessage(clientMessageId, conversationKey, text, { attachments });
|
|
3125
3195
|
pendingUserMessageIds.add(clientMessageId);
|
|
3126
|
-
if (isCurrentConversation(conversationKey)) {
|
|
3196
|
+
if (isCurrentConversation(conversationKey) && isConversationWindowAtTail()) {
|
|
3127
3197
|
appendMessage({
|
|
3128
3198
|
role: 'user',
|
|
3129
3199
|
text,
|
|
@@ -3807,6 +3877,8 @@ export function renderMessages(msgs, scroll = true, meta = {}) {
|
|
|
3807
3877
|
return false;
|
|
3808
3878
|
}
|
|
3809
3879
|
deferredMessageRender = null;
|
|
3880
|
+
// Other rows: whoever renders them puts the reader in place.
|
|
3881
|
+
releaseReadingAnchorHold();
|
|
3810
3882
|
const messageById = new Map(
|
|
3811
3883
|
ordered
|
|
3812
3884
|
.map((item) => [String(item?.id || '').trim(), item])
|
|
@@ -3864,7 +3936,9 @@ export function renderMessages(msgs, scroll = true, meta = {}) {
|
|
|
3864
3936
|
// in as an extra row, and another conversation's bubble must not leak into
|
|
3865
3937
|
// this one (it is dropped instead, so the next restore builds afresh).
|
|
3866
3938
|
const thinkingNodeInTranscript = !!thinkingNode && thinkingNode.parentNode === el;
|
|
3939
|
+
// Nor under a window that ends above the conversation's end (showThinking).
|
|
3867
3940
|
const preservedThinkingNode = (thinkingNodeInTranscript
|
|
3941
|
+
&& !hasMoreNewer
|
|
3868
3942
|
&& thinkingNode.dataset.conversationId === renderConversationKey) ? thinkingNode : null;
|
|
3869
3943
|
if (thinkingNodeInTranscript && !preservedThinkingNode) removeThinking();
|
|
3870
3944
|
if (!ordered.length && !preservedPendingNodes.length && !preservedThinkingNode) {
|
|
@@ -3978,6 +4052,342 @@ export function isConversationWindowAtTail() {
|
|
|
3978
4052
|
return !conversationHistoryState.hasMoreNewer;
|
|
3979
4053
|
}
|
|
3980
4054
|
|
|
4055
|
+
/**
|
|
4056
|
+
* A live row for `conversationId` arrived (a message, a reply). True when it
|
|
4057
|
+
* is held back instead of put on the page: the window on the page is that
|
|
4058
|
+
* conversation's and ends above its end (transcript-island.mjs). The window
|
|
4059
|
+
* keeps loading newer pages from its own newest row, and the jump-to-bottom
|
|
4060
|
+
* button counts the row.
|
|
4061
|
+
*/
|
|
4062
|
+
export function holdBackLiveRow(conversationId, messageId) {
|
|
4063
|
+
const id = String(conversationId || '').trim();
|
|
4064
|
+
if (!id || isConversationWindowAtTail() || getRenderedConversationId() !== id) return false;
|
|
4065
|
+
heldBackLiveRows.note(id, messageId);
|
|
4066
|
+
// A reload whose request was on its way may lack this row as it would one
|
|
4067
|
+
// put on the page: counted, so it is asked again (loadKeepingLiveRows)
|
|
4068
|
+
// rather than rendered over the rows held.
|
|
4069
|
+
noteTranscriptLiveRow();
|
|
4070
|
+
refreshJumpButton();
|
|
4071
|
+
return true;
|
|
4072
|
+
}
|
|
4073
|
+
|
|
4074
|
+
/** How many live rows wait below the window on the page. */
|
|
4075
|
+
export function getHeldBackLiveRowCount() {
|
|
4076
|
+
return heldBackLiveRows.count(getRenderedConversationId());
|
|
4077
|
+
}
|
|
4078
|
+
|
|
4079
|
+
/** The ids of the live rows that wait below the window on the page, oldest first. */
|
|
4080
|
+
export function getHeldBackLiveRowIds() {
|
|
4081
|
+
return heldBackLiveRows.ids(getRenderedConversationId());
|
|
4082
|
+
}
|
|
4083
|
+
|
|
4084
|
+
function renderedMessageIds() {
|
|
4085
|
+
const el = getMessagesElement();
|
|
4086
|
+
if (!el) return [];
|
|
4087
|
+
return Array.from(readingAnchorRows(el), (node) => String(node.dataset.messageId || '').trim());
|
|
4088
|
+
}
|
|
4089
|
+
|
|
4090
|
+
// The running turn's live bubble, drawn from what the page kept of it (its
|
|
4091
|
+
// thoughts, tools and text arrived while no bubble was drawn).
|
|
4092
|
+
function showLiveBubbleOfRunningTurn() {
|
|
4093
|
+
if (document.getElementById('thinking-indicator')) return;
|
|
4094
|
+
const turn = getActiveTurnForConversation(currentConvId);
|
|
4095
|
+
const messageId = String(turn?.messageId || '').trim();
|
|
4096
|
+
if (!messageId || String(turn?.status || '').trim().toLowerCase() !== 'processing') return;
|
|
4097
|
+
showThinking(messageId, false);
|
|
4098
|
+
renderThinkingStream();
|
|
4099
|
+
renderThinkingActivities();
|
|
4100
|
+
renderThinkingThoughts();
|
|
4101
|
+
renderRestoredSubagentBubbles(messageId);
|
|
4102
|
+
}
|
|
4103
|
+
|
|
4104
|
+
/** The conversation whose rows the transcript holds; during a switch, still the one left. */
|
|
4105
|
+
export function getRenderedConversationId() {
|
|
4106
|
+
return String(conversationHistoryState.conversationId || '').trim();
|
|
4107
|
+
}
|
|
4108
|
+
|
|
4109
|
+
// The message rows, without the live bubble: it carries its prompt's id.
|
|
4110
|
+
function readingAnchorRows(el) {
|
|
4111
|
+
return el.querySelectorAll(':scope > .msg[data-message-id]:not(#thinking-indicator)');
|
|
4112
|
+
}
|
|
4113
|
+
|
|
4114
|
+
/**
|
|
4115
|
+
* Where the reader is in the transcript on the page
|
|
4116
|
+
* (transcript-reading-anchor.mjs), for the conversation whose rows it holds;
|
|
4117
|
+
* null when no row is there.
|
|
4118
|
+
*/
|
|
4119
|
+
export function captureConversationReadingAnchor() {
|
|
4120
|
+
const el = getMessagesElement();
|
|
4121
|
+
const conversationId = getRenderedConversationId();
|
|
4122
|
+
if (!el || !conversationId) return null;
|
|
4123
|
+
const rows = readingAnchorRows(el);
|
|
4124
|
+
if (!rows.length) return null;
|
|
4125
|
+
const paneTop = el.getBoundingClientRect().top;
|
|
4126
|
+
const rectAt = (index) => rows[index].getBoundingClientRect();
|
|
4127
|
+
const anchor = captureReadingAnchor({
|
|
4128
|
+
rowCount: rows.length,
|
|
4129
|
+
rowAt: (index) => {
|
|
4130
|
+
const rect = rectAt(index);
|
|
4131
|
+
return { messageId: rows[index].dataset.messageId, top: rect.top, bottom: rect.bottom };
|
|
4132
|
+
},
|
|
4133
|
+
paneTop,
|
|
4134
|
+
atTail: isMessagesNearBottom() && isConversationWindowAtTail(),
|
|
4135
|
+
});
|
|
4136
|
+
return anchor ? { conversationId, anchor } : null;
|
|
4137
|
+
}
|
|
4138
|
+
|
|
4139
|
+
function readingAnchorRow(el, messageId) {
|
|
4140
|
+
const id = String(messageId || '').trim();
|
|
4141
|
+
return id ? el.querySelector(`:scope > .msg[data-message-id="${CSS.escape(id)}"]:not(#thinking-indicator)`) : null;
|
|
4142
|
+
}
|
|
4143
|
+
|
|
4144
|
+
/**
|
|
4145
|
+
* Puts the anchored message at its saved offset, or the reader at the end.
|
|
4146
|
+
* False when the message is not on the page. The place is then held while
|
|
4147
|
+
* the images on the page are still loading (holdReadingAnchorWhileMediaLoads).
|
|
4148
|
+
*/
|
|
4149
|
+
export function restoreConversationReadingAnchor(anchor) {
|
|
4150
|
+
const el = getMessagesElement();
|
|
4151
|
+
if (!el || !anchor) return false;
|
|
4152
|
+
if (anchor.atTail) {
|
|
4153
|
+
releaseReadingAnchorHold();
|
|
4154
|
+
el.scrollTop = el.scrollHeight;
|
|
4155
|
+
return true;
|
|
4156
|
+
}
|
|
4157
|
+
const row = readingAnchorRow(el, anchor.messageId);
|
|
4158
|
+
if (!row) return false;
|
|
4159
|
+
const delta = readingAnchorScrollDelta({
|
|
4160
|
+
rowTop: row.getBoundingClientRect().top,
|
|
4161
|
+
paneTop: el.getBoundingClientRect().top,
|
|
4162
|
+
offsetPx: anchor.offsetPx,
|
|
4163
|
+
});
|
|
4164
|
+
if (Math.abs(delta) > 0.5) el.scrollTop += delta;
|
|
4165
|
+
holdReadingAnchorWhileMediaLoads(el, anchor);
|
|
4166
|
+
return true;
|
|
4167
|
+
}
|
|
4168
|
+
|
|
4169
|
+
// Images reserve no height until their bytes are in (the attachment payload
|
|
4170
|
+
// carries no dimensions), so one above the place read that arrives after the
|
|
4171
|
+
// place was put back pushes it down by its height. Chrome's scroll anchoring
|
|
4172
|
+
// absorbs that; browsers without it (iOS Safari) do not. Long enough for a
|
|
4173
|
+
// slow link, short enough that a forgotten hold cannot surprise anyone.
|
|
4174
|
+
const READING_ANCHOR_HOLD_MAX_MS = 10_000;
|
|
4175
|
+
let readingAnchorHold = null;
|
|
4176
|
+
|
|
4177
|
+
/**
|
|
4178
|
+
* Lets go of the place held while images load: whoever calls this moves the
|
|
4179
|
+
* reader themselves (the jump to the end, an own send).
|
|
4180
|
+
*/
|
|
4181
|
+
export function releaseReadingAnchorHold() {
|
|
4182
|
+
const hold = readingAnchorHold;
|
|
4183
|
+
if (!hold) return;
|
|
4184
|
+
readingAnchorHold = null;
|
|
4185
|
+
hold.cleanup();
|
|
4186
|
+
}
|
|
4187
|
+
|
|
4188
|
+
function pendingTranscriptImages(el) {
|
|
4189
|
+
return Array.from(el.querySelectorAll('img')).filter((img) => !img.complete);
|
|
4190
|
+
}
|
|
4191
|
+
|
|
4192
|
+
/**
|
|
4193
|
+
* Keeps the anchored message at its offset while images in the transcript
|
|
4194
|
+
* are loading: each one that is laid out at its height (a ResizeObserver
|
|
4195
|
+
* fires before the frame is painted; load and error events as a fallback)
|
|
4196
|
+
* scrolls by what it pushed. Lets go when they are all in, after
|
|
4197
|
+
* READING_ANCHOR_HOLD_MAX_MS, or as soon as the reader scrolls: a wheel, a
|
|
4198
|
+
* touch or a key, or a change of scrollTop that no growth above the row
|
|
4199
|
+
* explains (a drag of the scrollbar, a jump to the end, an own send). Growth
|
|
4200
|
+
* below the row (a reply streaming in at the end) is not the hold's to
|
|
4201
|
+
* correct (readingAnchorHoldStep). A reader at the end has nothing to hold
|
|
4202
|
+
* here: the jump-to-bottom follower keeps them at the end.
|
|
4203
|
+
*/
|
|
4204
|
+
function holdReadingAnchorWhileMediaLoads(el, anchor) {
|
|
4205
|
+
releaseReadingAnchorHold();
|
|
4206
|
+
if (!el || !anchor || anchor.atTail || !anchor.messageId) return;
|
|
4207
|
+
const pending = pendingTranscriptImages(el);
|
|
4208
|
+
if (!pending.length) return;
|
|
4209
|
+
const startRow = readingAnchorRow(el, anchor.messageId);
|
|
4210
|
+
if (!startRow) return;
|
|
4211
|
+
// Where the row sits in the content and the scroll, as of the last look:
|
|
4212
|
+
// the next look corrects by what moved the row in the content only.
|
|
4213
|
+
const seen = {
|
|
4214
|
+
rowContentTop: readingAnchorContentTop({
|
|
4215
|
+
rowTop: startRow.getBoundingClientRect().top,
|
|
4216
|
+
paneTop: el.getBoundingClientRect().top,
|
|
4217
|
+
scrollTop: el.scrollTop,
|
|
4218
|
+
}),
|
|
4219
|
+
scrollTop: el.scrollTop,
|
|
4220
|
+
};
|
|
4221
|
+
const check = () => {
|
|
4222
|
+
if (readingAnchorHold !== hold) return;
|
|
4223
|
+
const row = readingAnchorRow(el, anchor.messageId);
|
|
4224
|
+
if (!row) {
|
|
4225
|
+
releaseReadingAnchorHold();
|
|
4226
|
+
return;
|
|
4227
|
+
}
|
|
4228
|
+
const step = readingAnchorHoldStep({
|
|
4229
|
+
rowTop: row.getBoundingClientRect().top,
|
|
4230
|
+
paneTop: el.getBoundingClientRect().top,
|
|
4231
|
+
scrollTop: el.scrollTop,
|
|
4232
|
+
seen,
|
|
4233
|
+
});
|
|
4234
|
+
if (step.action === 'release') {
|
|
4235
|
+
releaseReadingAnchorHold();
|
|
4236
|
+
return;
|
|
4237
|
+
}
|
|
4238
|
+
if (step.action === 'correct') el.scrollTop += step.delta;
|
|
4239
|
+
seen.rowContentTop = step.rowContentTop;
|
|
4240
|
+
// Read back: the browser can clamp the correction.
|
|
4241
|
+
seen.scrollTop = el.scrollTop;
|
|
4242
|
+
};
|
|
4243
|
+
const onMediaSettled = () => {
|
|
4244
|
+
check();
|
|
4245
|
+
if (readingAnchorHold === hold && !pendingTranscriptImages(el).length) releaseReadingAnchorHold();
|
|
4246
|
+
};
|
|
4247
|
+
const onReaderInput = () => releaseReadingAnchorHold();
|
|
4248
|
+
// A key can scroll the pane from anywhere but a field being typed in (the
|
|
4249
|
+
// composer, a card's reply): typing there moves nothing.
|
|
4250
|
+
const onKeyDown = (event) => {
|
|
4251
|
+
const target = event?.target;
|
|
4252
|
+
if (target && (target.tagName === 'TEXTAREA' || target.tagName === 'INPUT' || target.isContentEditable === true)) return;
|
|
4253
|
+
releaseReadingAnchorHold();
|
|
4254
|
+
};
|
|
4255
|
+
const observer = typeof ResizeObserver === 'function' ? new ResizeObserver(check) : null;
|
|
4256
|
+
for (const img of pending) observer?.observe(img);
|
|
4257
|
+
el.addEventListener('load', onMediaSettled, true);
|
|
4258
|
+
el.addEventListener('error', onMediaSettled, true);
|
|
4259
|
+
// A video's box changes when its metadata is in.
|
|
4260
|
+
el.addEventListener('loadedmetadata', check, true);
|
|
4261
|
+
el.addEventListener('scroll', check, { passive: true });
|
|
4262
|
+
el.addEventListener('wheel', onReaderInput, { passive: true });
|
|
4263
|
+
el.addEventListener('touchstart', onReaderInput, { passive: true });
|
|
4264
|
+
document.addEventListener('keydown', onKeyDown, true);
|
|
4265
|
+
const timer = setTimeout(releaseReadingAnchorHold, READING_ANCHOR_HOLD_MAX_MS);
|
|
4266
|
+
const hold = {
|
|
4267
|
+
cleanup() {
|
|
4268
|
+
clearTimeout(timer);
|
|
4269
|
+
observer?.disconnect();
|
|
4270
|
+
el.removeEventListener('load', onMediaSettled, true);
|
|
4271
|
+
el.removeEventListener('error', onMediaSettled, true);
|
|
4272
|
+
el.removeEventListener('loadedmetadata', check, true);
|
|
4273
|
+
el.removeEventListener('scroll', check);
|
|
4274
|
+
el.removeEventListener('wheel', onReaderInput);
|
|
4275
|
+
el.removeEventListener('touchstart', onReaderInput);
|
|
4276
|
+
document.removeEventListener('keydown', onKeyDown, true);
|
|
4277
|
+
},
|
|
4278
|
+
};
|
|
4279
|
+
readingAnchorHold = hold;
|
|
4280
|
+
}
|
|
4281
|
+
|
|
4282
|
+
/**
|
|
4283
|
+
* The window on the page no longer meets the end of the conversation (more
|
|
4284
|
+
* was written than one page holds): newer rows load as after a jump into
|
|
4285
|
+
* the history, and the jump-to-bottom button loads the end.
|
|
4286
|
+
*/
|
|
4287
|
+
export function detachConversationWindowFromTail() {
|
|
4288
|
+
const newestMessageId = String(conversationHistoryState.newestMessageId || '').trim();
|
|
4289
|
+
setConversationHistoryState({ ...conversationHistoryState, hasMoreNewer: true });
|
|
4290
|
+
// A live bubble on the page now sits under a window that ends above the
|
|
4291
|
+
// end, below rows that are not loaded: it goes until the window meets the
|
|
4292
|
+
// end again (showThinking draws none meanwhile).
|
|
4293
|
+
takeThinkingBubbleOff();
|
|
4294
|
+
conversationFutureLoader.reset({
|
|
4295
|
+
hasMore: true,
|
|
4296
|
+
nextCursor: {
|
|
4297
|
+
afterMessageId: newestMessageId || null,
|
|
4298
|
+
afterTimestamp: String(conversationHistoryState.newestMessageTimestamp || '').trim() || null,
|
|
4299
|
+
},
|
|
4300
|
+
});
|
|
4301
|
+
}
|
|
4302
|
+
|
|
4303
|
+
// One message as the transcript's snapshot key sees it.
|
|
4304
|
+
function messageRenderKey(message) {
|
|
4305
|
+
return buildMessageSnapshotKey([message], { conversationId: '', hasMoreOlder: false, hasMoreNewer: false });
|
|
4306
|
+
}
|
|
4307
|
+
|
|
4308
|
+
/**
|
|
4309
|
+
* Whether the transcript on the page can take a merge of `previousMessages`'
|
|
4310
|
+
* window (mergeMessagesIntoTranscript) rather than a full render: not while
|
|
4311
|
+
* a bubble still waits for its send, the status view is up, a selection is
|
|
4312
|
+
* held, another conversation's rows are on the page, or a row of the window
|
|
4313
|
+
* is missing from it (what is merged in is placed next to those rows, and a
|
|
4314
|
+
* changed one is rebuilt where it stands).
|
|
4315
|
+
*/
|
|
4316
|
+
export function canMergeIntoTranscript(previousMessages, conversationId) {
|
|
4317
|
+
const el = getMessagesElement();
|
|
4318
|
+
const id = String(conversationId || '').trim();
|
|
4319
|
+
if (!el || !id) return false;
|
|
4320
|
+
if (isChatInteractionHeld()
|
|
4321
|
+
|| el.querySelector('.status-view')
|
|
4322
|
+
|| getRenderedConversationId() !== id
|
|
4323
|
+
|| hasPendingUserMessageForConversation(id)) {
|
|
4324
|
+
return false;
|
|
4325
|
+
}
|
|
4326
|
+
const rendered = new Set(Array.from(readingAnchorRows(el), (node) => String(node.dataset.messageId || '').trim()));
|
|
4327
|
+
for (const message of previousMessages || []) {
|
|
4328
|
+
const messageId = String(message?.id || '').trim();
|
|
4329
|
+
if (messageId && !rendered.has(messageId)) return false;
|
|
4330
|
+
}
|
|
4331
|
+
return true;
|
|
4332
|
+
}
|
|
4333
|
+
|
|
4334
|
+
/**
|
|
4335
|
+
* Puts a merged window (mergeTailPage) on the page without rebuilding it:
|
|
4336
|
+
* the rows the newest page changed are rebuilt where they stand, its new ones
|
|
4337
|
+
* are put in their place, the rows it dropped are taken off, every other row
|
|
4338
|
+
* stays as it is under the reader.
|
|
4339
|
+
* Falls back to a full render when the page holds rows the merge cannot
|
|
4340
|
+
* place (a bubble still waiting for its send, the status view, a held
|
|
4341
|
+
* selection). Returns true when the transcript changed.
|
|
4342
|
+
*/
|
|
4343
|
+
export function mergeMessagesIntoTranscript(previousMessages, msgs, meta = {}) {
|
|
4344
|
+
const el = getMessagesElement();
|
|
4345
|
+
if (!el) return false;
|
|
4346
|
+
const conversationId = String(meta.conversationId || currentConvId || '').trim();
|
|
4347
|
+
if (!canMergeIntoTranscript(previousMessages, conversationId)) return renderMessages(msgs, false, meta);
|
|
4348
|
+
const ordered = sortConversationMessages(msgs || []);
|
|
4349
|
+
const snapshotKey = buildMessageSnapshotKey(ordered, meta);
|
|
4350
|
+
if (snapshotKey === lastRenderedMessageSnapshotKey) return false;
|
|
4351
|
+
const previousById = new Map((previousMessages || []).map((m) => [String(m?.id || '').trim(), m]));
|
|
4352
|
+
const messageById = new Map(ordered.map((m) => [String(m?.id || '').trim(), m]).filter(([id]) => !!id));
|
|
4353
|
+
let previousId = '';
|
|
4354
|
+
withSuspendedSeparatorSync(() => {
|
|
4355
|
+
// A window row the merge left out was deleted while the reader was away
|
|
4356
|
+
// (mergeTailPage drops it): it leaves the page too. Taken off first, so
|
|
4357
|
+
// nothing merged in is placed next to it.
|
|
4358
|
+
for (const id of previousById.keys()) {
|
|
4359
|
+
if (!id || messageById.has(id)) continue;
|
|
4360
|
+
el.querySelector(`:scope > .msg[data-message-id="${CSS.escape(id)}"]:not(#thinking-indicator)`)?.remove();
|
|
4361
|
+
seenMessageIds.delete(id);
|
|
4362
|
+
}
|
|
4363
|
+
for (const m of ordered) {
|
|
4364
|
+
const id = String(m?.id || '').trim();
|
|
4365
|
+
if (!id) continue;
|
|
4366
|
+
const before = previousById.get(id);
|
|
4367
|
+
if (!before) {
|
|
4368
|
+
const threadAnchor = getMessageThreadAnchor(m, messageById);
|
|
4369
|
+
appendMessage(m, false, id, true, threadAnchor !== id ? threadAnchor : (previousId || null), false);
|
|
4370
|
+
} else if (messageRenderKey(before) !== messageRenderKey(m)) {
|
|
4371
|
+
replaceRenderedMessage(m, id);
|
|
4372
|
+
}
|
|
4373
|
+
previousId = id;
|
|
4374
|
+
}
|
|
4375
|
+
});
|
|
4376
|
+
const newest = ordered[ordered.length - 1] || null;
|
|
4377
|
+
setConversationHistoryState({
|
|
4378
|
+
...conversationHistoryState,
|
|
4379
|
+
conversationId,
|
|
4380
|
+
newestMessageId: String(newest?.id || conversationHistoryState.newestMessageId || '').trim(),
|
|
4381
|
+
newestMessageTimestamp: String(newest?.timestamp || conversationHistoryState.newestMessageTimestamp || '').trim(),
|
|
4382
|
+
loadedMessageCount: ordered.length,
|
|
4383
|
+
});
|
|
4384
|
+
syncSeparatorsNow();
|
|
4385
|
+
renderRelayQuestions();
|
|
4386
|
+
renderRelayBoards();
|
|
4387
|
+
lastRenderedMessageSnapshotKey = snapshotKey;
|
|
4388
|
+
return true;
|
|
4389
|
+
}
|
|
4390
|
+
|
|
3981
4391
|
export function focusConversationMessageById(messageId, { behavior = 'smooth', block = 'center' } = {}) {
|
|
3982
4392
|
const id = String(messageId || '').trim();
|
|
3983
4393
|
if (!id) return false;
|
|
@@ -4025,6 +4435,19 @@ export function compactCurrentConversation() {
|
|
|
4025
4435
|
// edit re-arms the warning.
|
|
4026
4436
|
let unknownCommandWarned = null;
|
|
4027
4437
|
|
|
4438
|
+
// An own message goes to the end of the conversation. Sent from a window that
|
|
4439
|
+
// ends above it, its row put under the window would sit below rows that are
|
|
4440
|
+
// not loaded, and would move the newer pages' cursor past them; no live
|
|
4441
|
+
// bubble or reply would be drawn there either. So the end is loaded first,
|
|
4442
|
+
// as the jump-to-bottom button does, and the row, the bubble and the reply
|
|
4443
|
+
// follow at the end, whatever the scroll-on-send setting says: the end is
|
|
4444
|
+
// where the message is. Resolves once the end is on the page, or its load
|
|
4445
|
+
// failed (then the window still ends above, and no row is put under it).
|
|
4446
|
+
function loadEndForOwnSend() {
|
|
4447
|
+
if (isConversationWindowAtTail()) return Promise.resolve();
|
|
4448
|
+
return jumpTranscriptToBottom();
|
|
4449
|
+
}
|
|
4450
|
+
|
|
4028
4451
|
export async function sendMessage() {
|
|
4029
4452
|
// The reader's own send, as of now: what follows it scrolls to the end
|
|
4030
4453
|
// only while they have not scrolled since.
|
|
@@ -4126,7 +4549,10 @@ export async function sendMessage() {
|
|
|
4126
4549
|
let attachments = [];
|
|
4127
4550
|
let clientMessageId = null;
|
|
4128
4551
|
try {
|
|
4552
|
+
// Alongside the upload: the end is loaded while the files go up.
|
|
4553
|
+
const endLoaded = viewingSendConversation() ? loadEndForOwnSend() : null;
|
|
4129
4554
|
attachments = await uploadAttachments(draftAttachments);
|
|
4555
|
+
await endLoaded;
|
|
4130
4556
|
|
|
4131
4557
|
const isNew = !targetConversationId;
|
|
4132
4558
|
const msgTimestamp = new Date().toISOString();
|
|
@@ -4148,7 +4574,11 @@ export async function sendMessage() {
|
|
|
4148
4574
|
releaseComposerFocusAfterSend(input);
|
|
4149
4575
|
// Asked before the bubble is on the page.
|
|
4150
4576
|
const follow = ownSendFollows(send);
|
|
4151
|
-
|
|
4577
|
+
// Not under a window that still ends above the end (loadEndForOwnSend
|
|
4578
|
+
// failed): the row comes with the end.
|
|
4579
|
+
if (isConversationWindowAtTail()) {
|
|
4580
|
+
appendMessage({ role: 'user', text, model: selectedModel, mode: selectedMode, timestamp: msgTimestamp, attachments }, follow, clientMessageId, true);
|
|
4581
|
+
}
|
|
4152
4582
|
scrollAfterOwnSend(send);
|
|
4153
4583
|
}
|
|
4154
4584
|
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// "Messages kept per conversation for switching back" (Settings › General):
|
|
2
|
+
// how many loaded messages the page keeps of a conversation it leaves, so a
|
|
3
|
+
// switch back shows the same rows. A setting of this browser, kept in
|
|
4
|
+
// localStorage like the scroll-on-send one. The slider runs from 100 to 1,000
|
|
5
|
+
// by 100 and has one more step at the far end, no limit. The document and
|
|
6
|
+
// the storage are passed in by the tests.
|
|
7
|
+
|
|
8
|
+
export const WINDOW_RETENTION_STORAGE_KEY = 'copilot_conversation_window_retention';
|
|
9
|
+
export const WINDOW_RETENTION_SLIDER_ID = 'conversation-window-retention-slider';
|
|
10
|
+
export const WINDOW_RETENTION_VALUE_ID = 'conversation-window-retention-value';
|
|
11
|
+
export const WINDOW_RETENTION_DEFAULT = 200;
|
|
12
|
+
export const WINDOW_RETENTION_MIN = 100;
|
|
13
|
+
export const WINDOW_RETENTION_MAX = 1000;
|
|
14
|
+
export const WINDOW_RETENTION_STEP = 100;
|
|
15
|
+
/** The slider's last step: no limit. */
|
|
16
|
+
export const WINDOW_RETENTION_UNLIMITED_POSITION = WINDOW_RETENTION_MAX + WINDOW_RETENTION_STEP;
|
|
17
|
+
const UNLIMITED_TOKEN = 'none';
|
|
18
|
+
|
|
19
|
+
function clampToStep(value) {
|
|
20
|
+
const stepped = Math.round(value / WINDOW_RETENTION_STEP) * WINDOW_RETENTION_STEP;
|
|
21
|
+
return Math.min(WINDOW_RETENTION_MAX, Math.max(WINDOW_RETENTION_MIN, stepped));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The cap a stored value stands for: a count, or Infinity for no limit. */
|
|
25
|
+
export function parseWindowRetention(stored) {
|
|
26
|
+
const text = String(stored ?? '').trim().toLowerCase();
|
|
27
|
+
if (!text) return WINDOW_RETENTION_DEFAULT;
|
|
28
|
+
if (text === UNLIMITED_TOKEN) return Infinity;
|
|
29
|
+
const value = Number(text);
|
|
30
|
+
if (!Number.isFinite(value) || value <= 0) return WINDOW_RETENTION_DEFAULT;
|
|
31
|
+
return clampToStep(value);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function serializeWindowRetention(cap) {
|
|
35
|
+
return Number.isFinite(Number(cap)) ? String(clampToStep(Number(cap))) : UNLIMITED_TOKEN;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The cap at a slider position; the far end is no limit. */
|
|
39
|
+
export function windowRetentionFromSlider(position) {
|
|
40
|
+
const value = Number(position);
|
|
41
|
+
if (!Number.isFinite(value)) return WINDOW_RETENTION_DEFAULT;
|
|
42
|
+
if (value >= WINDOW_RETENTION_UNLIMITED_POSITION) return Infinity;
|
|
43
|
+
return clampToStep(value);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function sliderPositionForWindowRetention(cap) {
|
|
47
|
+
return Number.isFinite(Number(cap)) ? clampToStep(Number(cap)) : WINDOW_RETENTION_UNLIMITED_POSITION;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function formatWindowRetention(cap) {
|
|
51
|
+
if (!Number.isFinite(Number(cap))) return 'No limit';
|
|
52
|
+
return `${clampToStep(Number(cap)).toLocaleString('en-US')} messages`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function storage() {
|
|
56
|
+
try { return globalThis.localStorage || null; } catch { return null; }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function readWindowRetention(store = storage()) {
|
|
60
|
+
try {
|
|
61
|
+
return parseWindowRetention(store?.getItem(WINDOW_RETENTION_STORAGE_KEY));
|
|
62
|
+
} catch {
|
|
63
|
+
return WINDOW_RETENTION_DEFAULT;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function showValue(doc, cap) {
|
|
68
|
+
const text = formatWindowRetention(cap);
|
|
69
|
+
const label = doc?.getElementById?.(WINDOW_RETENTION_VALUE_ID);
|
|
70
|
+
if (label) label.textContent = text;
|
|
71
|
+
doc?.getElementById?.(WINDOW_RETENTION_SLIDER_ID)?.setAttribute?.('aria-valuetext', text);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function syncWindowRetentionSlider(doc = globalThis.document, store = storage()) {
|
|
75
|
+
const cap = readWindowRetention(store);
|
|
76
|
+
const slider = doc?.getElementById?.(WINDOW_RETENTION_SLIDER_ID);
|
|
77
|
+
if (slider) slider.value = String(sliderPositionForWindowRetention(cap));
|
|
78
|
+
showValue(doc, cap);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** While the thumb moves: the label follows, nothing is saved. */
|
|
82
|
+
export function previewWindowRetention(position, { doc = globalThis.document } = {}) {
|
|
83
|
+
showValue(doc, windowRetentionFromSlider(position));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Saves the slider's value and hands the new cap to `onChange` (the kept
|
|
88
|
+
* conversations are cut down to it). Returns the cap.
|
|
89
|
+
*/
|
|
90
|
+
export function updateWindowRetention(position, { doc = globalThis.document, store = storage(), onChange = null } = {}) {
|
|
91
|
+
const cap = windowRetentionFromSlider(position);
|
|
92
|
+
try { store?.setItem(WINDOW_RETENTION_STORAGE_KEY, serializeWindowRetention(cap)); } catch {}
|
|
93
|
+
syncWindowRetentionSlider(doc, store);
|
|
94
|
+
if (typeof onChange === 'function') onChange(cap);
|
|
95
|
+
return cap;
|
|
96
|
+
}
|