@oar-sh/oar 0.9.11 → 0.9.13
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 +332 -0
- package/README.md +10 -10
- package/package.json +1 -1
- package/server/README.md +201 -12
- 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 +179 -0
- package/server/claude-worker/claude-session-worker.mjs +17 -0
- package/server/claude-worker/task-output-discovery.mjs +152 -0
- package/server/copilot-worker/copilot-continuation-signals.mjs +11 -5
- package/server/copilot-worker/copilot-sdk-session-process.mjs +169 -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 +19 -0
- package/server/grok-worker/acp-host-services.mjs +48 -6
- package/server/grok-worker/grok-background-tasks.mjs +248 -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/migrations/0018-background-task-rows.mjs +101 -0
- package/server/public/app/action-confirmations.js +12 -15
- package/server/public/app/api-client.js +70 -3
- package/server/public/app/background-tasks-view.mjs +334 -16
- package/server/public/app/bootstrap.js +102 -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-preferences.mjs +10 -4
- package/server/public/app/conversation-turn-state.mjs +1 -1
- package/server/public/app/conversation-view.js +462 -8
- package/server/public/app/conversation-window-retention.mjs +96 -0
- package/server/public/app/host-suspend-ui.js +45 -44
- package/server/public/app/journal-view.js +259 -67
- package/server/public/app/push-settings.js +1 -0
- package/server/public/app/relay-prompt-sanitizer.mjs +20 -6
- package/server/public/app/relay-restart-note-view.mjs +36 -0
- package/server/public/app/relay-restart-ui.js +568 -0
- package/server/public/app/settings-modal.js +162 -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 +401 -0
- package/server/public/app/task-console-watch.mjs +42 -0
- package/server/public/app/task-console.mjs +730 -0
- package/server/public/app/task-output-text.mjs +243 -0
- package/server/public/app/transcript-island.mjs +73 -0
- package/server/public/app/transcript-jump-bottom-view.mjs +42 -8
- package/server/public/app/transcript-jump-bottom.mjs +68 -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 +306 -6
- package/server/relay-tools.md +6 -0
- package/server/repositories/message-repository.mjs +11 -0
- package/server/repositories/question-repository.mjs +4 -0
- package/server/repositories/remote-relay-repository.mjs +4 -2
- package/server/routes/messages-routes.mjs +397 -23
- package/server/routes/relay-board-routes.mjs +19 -2
- package/server/routes/relay-restart-routes.mjs +73 -0
- package/server/routes/sessions-routes.mjs +208 -66
- package/server/server-runtime.mjs +269 -137
- package/server/services/about-service.mjs +13 -12
- package/server/services/background-task-persistence.mjs +133 -0
- package/server/services/background-task-store.mjs +470 -6
- package/server/services/cli-process-runner.mjs +4 -21
- package/server/services/conversation-history-page.mjs +199 -0
- package/server/services/conversation-model-fallback.mjs +57 -0
- package/server/services/host-suspend-command.mjs +2 -1
- package/server/services/preview-proxy-server.mjs +14 -1
- package/server/services/process-list-service.mjs +2 -1
- package/server/services/provider-model-selection.mjs +195 -0
- package/server/services/push-dispatch-service.mjs +9 -2
- package/server/services/relay-event-service.mjs +27 -9
- package/server/services/relay-prompt-sanitizer.mjs +18 -4
- package/server/services/relay-restart-picture.mjs +209 -0
- package/server/services/relay-restart-queue.mjs +380 -0
- package/server/services/relay-shutdown-gate.mjs +4 -1
- package/server/services/remote-relay-dispatcher.mjs +316 -20
- package/server/services/restart-resume-service.mjs +303 -0
- package/server/services/session-activity-tracker.mjs +6 -2
- package/server/services/session-worker-process-service.mjs +6 -4
- package/server/services/task-output-rooms.mjs +259 -0
- package/server/services/task-output-service.mjs +761 -0
- package/shared/claude-background-command-limit.mjs +83 -0
- package/shared/relay-events.mjs +55 -2
- package/shared/remote-relay-contract.mjs +47 -10
- package/shared/task-output-text.mjs +850 -0
- package/shared/worker-runtime/task-output-spool.mjs +181 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +96 -20
|
@@ -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';
|
|
@@ -65,6 +67,7 @@ import { closeSlashAutocomplete, handleSlashAutocompleteKey, updateSlashAutocomp
|
|
|
65
67
|
import { closeMentionAutocomplete, handleMentionAutocompleteKey, updateMentionAutocomplete } from './mention-autocomplete.mjs';
|
|
66
68
|
import { renderRemoteOriginBadgeHtml } from './remote-relay-origin-view.mjs';
|
|
67
69
|
import { isRelayEventMessage, renderRelayEventRowHtml } from './relay-event-view.mjs';
|
|
70
|
+
import { isRestartResumeNote, renderRestartResumeNoteHtml } from './relay-restart-note-view.mjs';
|
|
68
71
|
import {
|
|
69
72
|
AGENT_EXCHANGE_CLOSED_CLASS,
|
|
70
73
|
AGENT_EXCHANGE_HEAD_CLASS,
|
|
@@ -131,6 +134,7 @@ import {
|
|
|
131
134
|
shouldApplyIncomingDraftToComposer,
|
|
132
135
|
} from './conversation-draft-sync.mjs';
|
|
133
136
|
import { isChatInteractionHeld, selectionIntersectsNode } from './selection-guard.mjs';
|
|
137
|
+
import { captureReadingAnchor, readingAnchorContentTop, readingAnchorHoldStep, readingAnchorScrollDelta } from './transcript-reading-anchor.mjs';
|
|
134
138
|
import { captureReadingBlock, restoreReadingBlock } from './transcript-viewport-anchor.mjs';
|
|
135
139
|
import {
|
|
136
140
|
applyThinkingFollow,
|
|
@@ -142,10 +146,12 @@ import {
|
|
|
142
146
|
toggleThinkingFollowMode,
|
|
143
147
|
unobserveThinkingBubble,
|
|
144
148
|
} from './thinking-follow.js';
|
|
145
|
-
import { beginOwnSend, followsTranscriptEnd, nameOwnSendConversation, ownSendFollows, scrollAfterOwnSend } from './transcript-jump-bottom-view.mjs';
|
|
149
|
+
import { beginOwnSend, followsTranscriptEnd, jumpTranscriptToBottom, nameOwnSendConversation, ownSendFollows, refreshJumpButton, scrollAfterOwnSend } from './transcript-jump-bottom-view.mjs';
|
|
150
|
+
import { createHeldBackLiveRows, newerPageReachesEnd } from './transcript-island.mjs';
|
|
146
151
|
import { buildInFlightSnapshotKey } from './in-flight-snapshot.mjs';
|
|
147
152
|
import { computeStablePrefixLength, planListPatch } from './streaming-dom-patch.mjs';
|
|
148
153
|
import { prefixToolActivityEmoji } from './tool-activity-emoji.mjs';
|
|
154
|
+
import { conversationPayloadCache } from './conversation-payload-cache.mjs';
|
|
149
155
|
|
|
150
156
|
const CONVERSATION_HISTORY_PAGE_SIZE = 20;
|
|
151
157
|
const HISTORY_LOAD_MORE_ID = 'history-load-more';
|
|
@@ -239,6 +245,20 @@ let conversationHistoryState = {
|
|
|
239
245
|
loadingOlder: false,
|
|
240
246
|
loadingNewer: false,
|
|
241
247
|
};
|
|
248
|
+
// Live rows for a window that ends above the conversation's end: held back
|
|
249
|
+
// until the newer pages bring them (transcript-island.mjs).
|
|
250
|
+
const heldBackLiveRows = createHeldBackLiveRows();
|
|
251
|
+
|
|
252
|
+
// After every change of conversationHistoryState: a window that meets the
|
|
253
|
+
// end, or another conversation's, holds no rows back. The button's count
|
|
254
|
+
// changes without the page changing, so it is told.
|
|
255
|
+
function syncHeldBackLiveRows() {
|
|
256
|
+
const released = heldBackLiveRows.follow({
|
|
257
|
+
conversationId: conversationHistoryState.conversationId,
|
|
258
|
+
atTail: !conversationHistoryState.hasMoreNewer,
|
|
259
|
+
});
|
|
260
|
+
if (released) refreshJumpButton();
|
|
261
|
+
}
|
|
242
262
|
|
|
243
263
|
function renderImageEditTarget() {
|
|
244
264
|
const chip = document.getElementById('image-edit-target');
|
|
@@ -354,6 +374,8 @@ const conversationHistoryLoader = createInfiniteLoader({
|
|
|
354
374
|
const previousScrollTop = el.scrollTop;
|
|
355
375
|
const previousScrollHeight = el.scrollHeight;
|
|
356
376
|
const inserted = prependMessageNodes(page.items || []);
|
|
377
|
+
// The kept window grows with the page: a switch back shows these rows too.
|
|
378
|
+
if (!IS_SHARED_VIEW) conversationPayloadCache.addOlder(currentId, page.items || [], { hasMoreOlder: page.hasMore });
|
|
357
379
|
setConversationHistoryState({
|
|
358
380
|
conversationId: currentId,
|
|
359
381
|
hasMoreOlder: page.hasMore,
|
|
@@ -377,10 +399,18 @@ const conversationHistoryLoader = createInfiniteLoader({
|
|
|
377
399
|
syncSteeredTurnMerge(el);
|
|
378
400
|
// The page's agent exchanges fold before the height delta is measured.
|
|
379
401
|
syncAgentExchangeFolds(el);
|
|
402
|
+
// Here, not a frame later: the position taken above is the reader's only
|
|
403
|
+
// until they scroll again, and a scroll in that frame (a jump to the end
|
|
404
|
+
// and up again) was undone by it.
|
|
405
|
+
el.scrollTop = previousScrollTop + (el.scrollHeight - previousScrollHeight);
|
|
406
|
+
// The page's images have no height yet: the place read is held while
|
|
407
|
+
// they come in, as after a restore.
|
|
408
|
+
const place = captureConversationReadingAnchor();
|
|
409
|
+
if (place?.conversationId === currentId) holdReadingAnchorWhileMediaLoads(el, place.anchor);
|
|
410
|
+
// Once the loader has taken this page's cursor: still near the top, the
|
|
411
|
+
// next page loads.
|
|
380
412
|
requestAnimationFrame(() => {
|
|
381
|
-
if (
|
|
382
|
-
const nextScrollHeight = el.scrollHeight;
|
|
383
|
-
el.scrollTop = previousScrollTop + (nextScrollHeight - previousScrollHeight);
|
|
413
|
+
if (String(currentConvId || '').trim() !== currentId) return;
|
|
384
414
|
void conversationHistoryLoader.handleBoundaryDistance(el.scrollTop);
|
|
385
415
|
});
|
|
386
416
|
},
|
|
@@ -427,6 +457,24 @@ const conversationFutureLoader = createInfiniteLoader({
|
|
|
427
457
|
const currentId = String(currentConvId || '').trim();
|
|
428
458
|
if (!currentId || conversationPaginationCursorIsStale(currentId)) return;
|
|
429
459
|
const ordered = sortConversationMessages(page.items || []);
|
|
460
|
+
// The last page may predate a row held back meanwhile: one more is asked
|
|
461
|
+
// for, from this page's newest row. The loader reads hasMore and the
|
|
462
|
+
// cursor off this page once it is applied.
|
|
463
|
+
const heldBackIds = heldBackLiveRows.ids(currentId);
|
|
464
|
+
if (!page.hasMore && heldBackIds.length && !newerPageReachesEnd({
|
|
465
|
+
pageHasMore: page.hasMore,
|
|
466
|
+
pageMessageIds: ordered.map((m) => m?.id),
|
|
467
|
+
heldBackIds,
|
|
468
|
+
renderedIds: renderedMessageIds(),
|
|
469
|
+
})) {
|
|
470
|
+
const newest = ordered[ordered.length - 1];
|
|
471
|
+
page.hasMore = true;
|
|
472
|
+
page.nextCursor = {
|
|
473
|
+
afterMessageId: String(newest?.id || '').trim() || null,
|
|
474
|
+
afterTimestamp: String(newest?.timestamp || '').trim() || null,
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
if (!IS_SHARED_VIEW) conversationPayloadCache.addNewer(currentId, ordered, { hasMoreNewer: page.hasMore });
|
|
430
478
|
let inserted = 0;
|
|
431
479
|
withSuspendedSeparatorSync(() => {
|
|
432
480
|
for (const m of ordered) {
|
|
@@ -448,6 +496,9 @@ const conversationFutureLoader = createInfiniteLoader({
|
|
|
448
496
|
loadingOlder: conversationHistoryState.loadingOlder,
|
|
449
497
|
loadingNewer: false,
|
|
450
498
|
});
|
|
499
|
+
// The window meets the end: a turn still running gets its live bubble,
|
|
500
|
+
// which was not drawn under the window while it ended above.
|
|
501
|
+
if (!page.hasMore) showLiveBubbleOfRunningTurn();
|
|
451
502
|
},
|
|
452
503
|
onError: (error, { mode }) => {
|
|
453
504
|
if (mode === 'load') {
|
|
@@ -460,6 +511,7 @@ const conversationFutureLoader = createInfiniteLoader({
|
|
|
460
511
|
hasMoreNewer: state.hasMore,
|
|
461
512
|
loadingNewer: state.isLoading,
|
|
462
513
|
};
|
|
514
|
+
syncHeldBackLiveRows();
|
|
463
515
|
},
|
|
464
516
|
});
|
|
465
517
|
|
|
@@ -1177,6 +1229,7 @@ function resetConversationHistoryState() {
|
|
|
1177
1229
|
conversationFutureLoader.reset({ hasMore: false, nextCursor: null });
|
|
1178
1230
|
saveConversationLoadedMessageCount(conversationId, 0);
|
|
1179
1231
|
syncHistoryLoadMoreControl();
|
|
1232
|
+
syncHeldBackLiveRows();
|
|
1180
1233
|
}
|
|
1181
1234
|
|
|
1182
1235
|
function setConversationHistoryState(next = {}) {
|
|
@@ -1197,6 +1250,7 @@ function setConversationHistoryState(next = {}) {
|
|
|
1197
1250
|
conversationHistoryState.loadedMessageCount,
|
|
1198
1251
|
);
|
|
1199
1252
|
syncHistoryLoadMoreControl();
|
|
1253
|
+
syncHeldBackLiveRows();
|
|
1200
1254
|
}
|
|
1201
1255
|
|
|
1202
1256
|
function getConversationHistoryCursor() {
|
|
@@ -1287,6 +1341,21 @@ function createRelayEventNode(msg, msgId) {
|
|
|
1287
1341
|
return div;
|
|
1288
1342
|
}
|
|
1289
1343
|
|
|
1344
|
+
// The relay's note to carry on after a restart cut a turn off: a folded row
|
|
1345
|
+
// like an event row (relay-restart-note-view.mjs).
|
|
1346
|
+
function createRestartResumeNoteNode(msg, msgId) {
|
|
1347
|
+
const div = document.createElement('div');
|
|
1348
|
+
div.className = 'msg relay-event relay-restart-resume';
|
|
1349
|
+
if (msgId) div.dataset.messageId = msgId;
|
|
1350
|
+
const fingerprint = buildLiveMessageFingerprint({ ...msg, id: msgId || msg?.id || '' });
|
|
1351
|
+
div.dataset.messageRole = fingerprint.role || '';
|
|
1352
|
+
div.dataset.messageTextFingerprint = fingerprint.text || '';
|
|
1353
|
+
div.dataset.messageTimestamp = String(msg?.timestamp || '').trim();
|
|
1354
|
+
div.innerHTML = renderRestartResumeNoteHtml(msg, { escHtml, timeText: msg?.timestamp ? fmtDate(msg.timestamp) : '' });
|
|
1355
|
+
setAgentExchangeRow(div, msg, msgId);
|
|
1356
|
+
return div;
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1290
1359
|
// Agent exchanges (agent-exchange-fold-view.mjs): an agent's prompt folds
|
|
1291
1360
|
// into one row; this session's reply to it always shows. What the fold pass
|
|
1292
1361
|
// needs of a message is kept on its node when the row is built, so the pass
|
|
@@ -1420,6 +1489,7 @@ function createMessageNode(msg, msgId = null, force = false) {
|
|
|
1420
1489
|
// An event message the relay wrote into a watcher's conversation: a
|
|
1421
1490
|
// compact, foldable row, never a bubble of the user's.
|
|
1422
1491
|
if (isRelayEventMessage(msg)) return createRelayEventNode(msg, msgId);
|
|
1492
|
+
if (isRestartResumeNote(msg)) return createRestartResumeNoteNode(msg, msgId);
|
|
1423
1493
|
|
|
1424
1494
|
// A prompt another relay's agent sent carries `origin`: its header line is
|
|
1425
1495
|
// hidden (the badge below says the same), and a stray remote-relay mention
|
|
@@ -1873,6 +1943,16 @@ export function showThinking(messageId = null, autoScroll = true) {
|
|
|
1873
1943
|
const nextMessageId = String(messageId || '').trim();
|
|
1874
1944
|
if (nextMessageId) thinkingMessageId = nextMessageId;
|
|
1875
1945
|
const existing = document.getElementById('thinking-indicator');
|
|
1946
|
+
// A window that ends above the conversation's end shows no live bubble:
|
|
1947
|
+
// under it, it would sit below rows that are not loaded. The turn's
|
|
1948
|
+
// thoughts, tools and text are kept all the same; the bubble is drawn once
|
|
1949
|
+
// the window meets the end.
|
|
1950
|
+
if (!isConversationWindowAtTail()) {
|
|
1951
|
+
// Through the same path as removeThinking, so the separators and the
|
|
1952
|
+
// agent folds no longer show the bubble either; the turn's id stays.
|
|
1953
|
+
if (existing) takeThinkingBubbleOff();
|
|
1954
|
+
return;
|
|
1955
|
+
}
|
|
1876
1956
|
if (existing
|
|
1877
1957
|
&& existing.dataset.conversationId === thinkingConversationKey()
|
|
1878
1958
|
&& (!nextMessageId || String(existing.dataset.messageId || '') === nextMessageId)) {
|
|
@@ -1933,6 +2013,14 @@ export function showThinking(messageId = null, autoScroll = true) {
|
|
|
1933
2013
|
export function removeThinking() {
|
|
1934
2014
|
thinkingMessageId = null;
|
|
1935
2015
|
lastInFlightSnapshotKey = '';
|
|
2016
|
+
takeThinkingBubbleOff();
|
|
2017
|
+
}
|
|
2018
|
+
|
|
2019
|
+
// The live bubble leaves the page, with what hangs on it (its compaction
|
|
2020
|
+
// line, the agent folds' "working"). The turn it shows is not forgotten:
|
|
2021
|
+
// removeThinking does that; a window that stops reaching the end only hides
|
|
2022
|
+
// the bubble until it meets the end again (showThinking).
|
|
2023
|
+
function takeThinkingBubbleOff() {
|
|
1936
2024
|
const hadBubble = !!document.getElementById('thinking-indicator');
|
|
1937
2025
|
const wasCompacting = !!document.getElementById('thinking-indicator')?.dataset?.compactBoundary;
|
|
1938
2026
|
unobserveThinkingBubble();
|
|
@@ -3121,9 +3209,12 @@ async function resendStoppedSteer(conversationId, markerId) {
|
|
|
3121
3209
|
showTransientRelayNotice('Select a reasoning effort after refreshing model metadata.');
|
|
3122
3210
|
return;
|
|
3123
3211
|
}
|
|
3212
|
+
// As a send from the composer (loadEndForOwnSend): the Resend goes to
|
|
3213
|
+
// the end.
|
|
3214
|
+
if (isCurrentConversation(conversationKey)) await loadEndForOwnSend();
|
|
3124
3215
|
trackPendingUserMessage(clientMessageId, conversationKey, text, { attachments });
|
|
3125
3216
|
pendingUserMessageIds.add(clientMessageId);
|
|
3126
|
-
if (isCurrentConversation(conversationKey)) {
|
|
3217
|
+
if (isCurrentConversation(conversationKey) && isConversationWindowAtTail()) {
|
|
3127
3218
|
appendMessage({
|
|
3128
3219
|
role: 'user',
|
|
3129
3220
|
text,
|
|
@@ -3807,6 +3898,8 @@ export function renderMessages(msgs, scroll = true, meta = {}) {
|
|
|
3807
3898
|
return false;
|
|
3808
3899
|
}
|
|
3809
3900
|
deferredMessageRender = null;
|
|
3901
|
+
// Other rows: whoever renders them puts the reader in place.
|
|
3902
|
+
releaseReadingAnchorHold();
|
|
3810
3903
|
const messageById = new Map(
|
|
3811
3904
|
ordered
|
|
3812
3905
|
.map((item) => [String(item?.id || '').trim(), item])
|
|
@@ -3864,7 +3957,9 @@ export function renderMessages(msgs, scroll = true, meta = {}) {
|
|
|
3864
3957
|
// in as an extra row, and another conversation's bubble must not leak into
|
|
3865
3958
|
// this one (it is dropped instead, so the next restore builds afresh).
|
|
3866
3959
|
const thinkingNodeInTranscript = !!thinkingNode && thinkingNode.parentNode === el;
|
|
3960
|
+
// Nor under a window that ends above the conversation's end (showThinking).
|
|
3867
3961
|
const preservedThinkingNode = (thinkingNodeInTranscript
|
|
3962
|
+
&& !hasMoreNewer
|
|
3868
3963
|
&& thinkingNode.dataset.conversationId === renderConversationKey) ? thinkingNode : null;
|
|
3869
3964
|
if (thinkingNodeInTranscript && !preservedThinkingNode) removeThinking();
|
|
3870
3965
|
if (!ordered.length && !preservedPendingNodes.length && !preservedThinkingNode) {
|
|
@@ -3978,6 +4073,342 @@ export function isConversationWindowAtTail() {
|
|
|
3978
4073
|
return !conversationHistoryState.hasMoreNewer;
|
|
3979
4074
|
}
|
|
3980
4075
|
|
|
4076
|
+
/**
|
|
4077
|
+
* A live row for `conversationId` arrived (a message, a reply). True when it
|
|
4078
|
+
* is held back instead of put on the page: the window on the page is that
|
|
4079
|
+
* conversation's and ends above its end (transcript-island.mjs). The window
|
|
4080
|
+
* keeps loading newer pages from its own newest row, and the jump-to-bottom
|
|
4081
|
+
* button counts the row.
|
|
4082
|
+
*/
|
|
4083
|
+
export function holdBackLiveRow(conversationId, messageId) {
|
|
4084
|
+
const id = String(conversationId || '').trim();
|
|
4085
|
+
if (!id || isConversationWindowAtTail() || getRenderedConversationId() !== id) return false;
|
|
4086
|
+
heldBackLiveRows.note(id, messageId);
|
|
4087
|
+
// A reload whose request was on its way may lack this row as it would one
|
|
4088
|
+
// put on the page: counted, so it is asked again (loadKeepingLiveRows)
|
|
4089
|
+
// rather than rendered over the rows held.
|
|
4090
|
+
noteTranscriptLiveRow();
|
|
4091
|
+
refreshJumpButton();
|
|
4092
|
+
return true;
|
|
4093
|
+
}
|
|
4094
|
+
|
|
4095
|
+
/** How many live rows wait below the window on the page. */
|
|
4096
|
+
export function getHeldBackLiveRowCount() {
|
|
4097
|
+
return heldBackLiveRows.count(getRenderedConversationId());
|
|
4098
|
+
}
|
|
4099
|
+
|
|
4100
|
+
/** The ids of the live rows that wait below the window on the page, oldest first. */
|
|
4101
|
+
export function getHeldBackLiveRowIds() {
|
|
4102
|
+
return heldBackLiveRows.ids(getRenderedConversationId());
|
|
4103
|
+
}
|
|
4104
|
+
|
|
4105
|
+
function renderedMessageIds() {
|
|
4106
|
+
const el = getMessagesElement();
|
|
4107
|
+
if (!el) return [];
|
|
4108
|
+
return Array.from(readingAnchorRows(el), (node) => String(node.dataset.messageId || '').trim());
|
|
4109
|
+
}
|
|
4110
|
+
|
|
4111
|
+
// The running turn's live bubble, drawn from what the page kept of it (its
|
|
4112
|
+
// thoughts, tools and text arrived while no bubble was drawn).
|
|
4113
|
+
function showLiveBubbleOfRunningTurn() {
|
|
4114
|
+
if (document.getElementById('thinking-indicator')) return;
|
|
4115
|
+
const turn = getActiveTurnForConversation(currentConvId);
|
|
4116
|
+
const messageId = String(turn?.messageId || '').trim();
|
|
4117
|
+
if (!messageId || String(turn?.status || '').trim().toLowerCase() !== 'processing') return;
|
|
4118
|
+
showThinking(messageId, false);
|
|
4119
|
+
renderThinkingStream();
|
|
4120
|
+
renderThinkingActivities();
|
|
4121
|
+
renderThinkingThoughts();
|
|
4122
|
+
renderRestoredSubagentBubbles(messageId);
|
|
4123
|
+
}
|
|
4124
|
+
|
|
4125
|
+
/** The conversation whose rows the transcript holds; during a switch, still the one left. */
|
|
4126
|
+
export function getRenderedConversationId() {
|
|
4127
|
+
return String(conversationHistoryState.conversationId || '').trim();
|
|
4128
|
+
}
|
|
4129
|
+
|
|
4130
|
+
// The message rows, without the live bubble: it carries its prompt's id.
|
|
4131
|
+
function readingAnchorRows(el) {
|
|
4132
|
+
return el.querySelectorAll(':scope > .msg[data-message-id]:not(#thinking-indicator)');
|
|
4133
|
+
}
|
|
4134
|
+
|
|
4135
|
+
/**
|
|
4136
|
+
* Where the reader is in the transcript on the page
|
|
4137
|
+
* (transcript-reading-anchor.mjs), for the conversation whose rows it holds;
|
|
4138
|
+
* null when no row is there.
|
|
4139
|
+
*/
|
|
4140
|
+
export function captureConversationReadingAnchor() {
|
|
4141
|
+
const el = getMessagesElement();
|
|
4142
|
+
const conversationId = getRenderedConversationId();
|
|
4143
|
+
if (!el || !conversationId) return null;
|
|
4144
|
+
const rows = readingAnchorRows(el);
|
|
4145
|
+
if (!rows.length) return null;
|
|
4146
|
+
const paneTop = el.getBoundingClientRect().top;
|
|
4147
|
+
const rectAt = (index) => rows[index].getBoundingClientRect();
|
|
4148
|
+
const anchor = captureReadingAnchor({
|
|
4149
|
+
rowCount: rows.length,
|
|
4150
|
+
rowAt: (index) => {
|
|
4151
|
+
const rect = rectAt(index);
|
|
4152
|
+
return { messageId: rows[index].dataset.messageId, top: rect.top, bottom: rect.bottom };
|
|
4153
|
+
},
|
|
4154
|
+
paneTop,
|
|
4155
|
+
atTail: isMessagesNearBottom() && isConversationWindowAtTail(),
|
|
4156
|
+
});
|
|
4157
|
+
return anchor ? { conversationId, anchor } : null;
|
|
4158
|
+
}
|
|
4159
|
+
|
|
4160
|
+
function readingAnchorRow(el, messageId) {
|
|
4161
|
+
const id = String(messageId || '').trim();
|
|
4162
|
+
return id ? el.querySelector(`:scope > .msg[data-message-id="${CSS.escape(id)}"]:not(#thinking-indicator)`) : null;
|
|
4163
|
+
}
|
|
4164
|
+
|
|
4165
|
+
/**
|
|
4166
|
+
* Puts the anchored message at its saved offset, or the reader at the end.
|
|
4167
|
+
* False when the message is not on the page. The place is then held while
|
|
4168
|
+
* the images on the page are still loading (holdReadingAnchorWhileMediaLoads).
|
|
4169
|
+
*/
|
|
4170
|
+
export function restoreConversationReadingAnchor(anchor) {
|
|
4171
|
+
const el = getMessagesElement();
|
|
4172
|
+
if (!el || !anchor) return false;
|
|
4173
|
+
if (anchor.atTail) {
|
|
4174
|
+
releaseReadingAnchorHold();
|
|
4175
|
+
el.scrollTop = el.scrollHeight;
|
|
4176
|
+
return true;
|
|
4177
|
+
}
|
|
4178
|
+
const row = readingAnchorRow(el, anchor.messageId);
|
|
4179
|
+
if (!row) return false;
|
|
4180
|
+
const delta = readingAnchorScrollDelta({
|
|
4181
|
+
rowTop: row.getBoundingClientRect().top,
|
|
4182
|
+
paneTop: el.getBoundingClientRect().top,
|
|
4183
|
+
offsetPx: anchor.offsetPx,
|
|
4184
|
+
});
|
|
4185
|
+
if (Math.abs(delta) > 0.5) el.scrollTop += delta;
|
|
4186
|
+
holdReadingAnchorWhileMediaLoads(el, anchor);
|
|
4187
|
+
return true;
|
|
4188
|
+
}
|
|
4189
|
+
|
|
4190
|
+
// Images reserve no height until their bytes are in (the attachment payload
|
|
4191
|
+
// carries no dimensions), so one above the place read that arrives after the
|
|
4192
|
+
// place was put back pushes it down by its height. Chrome's scroll anchoring
|
|
4193
|
+
// absorbs that; browsers without it (iOS Safari) do not. Long enough for a
|
|
4194
|
+
// slow link, short enough that a forgotten hold cannot surprise anyone.
|
|
4195
|
+
const READING_ANCHOR_HOLD_MAX_MS = 10_000;
|
|
4196
|
+
let readingAnchorHold = null;
|
|
4197
|
+
|
|
4198
|
+
/**
|
|
4199
|
+
* Lets go of the place held while images load: whoever calls this moves the
|
|
4200
|
+
* reader themselves (the jump to the end, an own send).
|
|
4201
|
+
*/
|
|
4202
|
+
export function releaseReadingAnchorHold() {
|
|
4203
|
+
const hold = readingAnchorHold;
|
|
4204
|
+
if (!hold) return;
|
|
4205
|
+
readingAnchorHold = null;
|
|
4206
|
+
hold.cleanup();
|
|
4207
|
+
}
|
|
4208
|
+
|
|
4209
|
+
function pendingTranscriptImages(el) {
|
|
4210
|
+
return Array.from(el.querySelectorAll('img')).filter((img) => !img.complete);
|
|
4211
|
+
}
|
|
4212
|
+
|
|
4213
|
+
/**
|
|
4214
|
+
* Keeps the anchored message at its offset while images in the transcript
|
|
4215
|
+
* are loading: each one that is laid out at its height (a ResizeObserver
|
|
4216
|
+
* fires before the frame is painted; load and error events as a fallback)
|
|
4217
|
+
* scrolls by what it pushed. Lets go when they are all in, after
|
|
4218
|
+
* READING_ANCHOR_HOLD_MAX_MS, or as soon as the reader scrolls: a wheel, a
|
|
4219
|
+
* touch or a key, or a change of scrollTop that no growth above the row
|
|
4220
|
+
* explains (a drag of the scrollbar, a jump to the end, an own send). Growth
|
|
4221
|
+
* below the row (a reply streaming in at the end) is not the hold's to
|
|
4222
|
+
* correct (readingAnchorHoldStep). A reader at the end has nothing to hold
|
|
4223
|
+
* here: the jump-to-bottom follower keeps them at the end.
|
|
4224
|
+
*/
|
|
4225
|
+
function holdReadingAnchorWhileMediaLoads(el, anchor) {
|
|
4226
|
+
releaseReadingAnchorHold();
|
|
4227
|
+
if (!el || !anchor || anchor.atTail || !anchor.messageId) return;
|
|
4228
|
+
const pending = pendingTranscriptImages(el);
|
|
4229
|
+
if (!pending.length) return;
|
|
4230
|
+
const startRow = readingAnchorRow(el, anchor.messageId);
|
|
4231
|
+
if (!startRow) return;
|
|
4232
|
+
// Where the row sits in the content and the scroll, as of the last look:
|
|
4233
|
+
// the next look corrects by what moved the row in the content only.
|
|
4234
|
+
const seen = {
|
|
4235
|
+
rowContentTop: readingAnchorContentTop({
|
|
4236
|
+
rowTop: startRow.getBoundingClientRect().top,
|
|
4237
|
+
paneTop: el.getBoundingClientRect().top,
|
|
4238
|
+
scrollTop: el.scrollTop,
|
|
4239
|
+
}),
|
|
4240
|
+
scrollTop: el.scrollTop,
|
|
4241
|
+
};
|
|
4242
|
+
const check = () => {
|
|
4243
|
+
if (readingAnchorHold !== hold) return;
|
|
4244
|
+
const row = readingAnchorRow(el, anchor.messageId);
|
|
4245
|
+
if (!row) {
|
|
4246
|
+
releaseReadingAnchorHold();
|
|
4247
|
+
return;
|
|
4248
|
+
}
|
|
4249
|
+
const step = readingAnchorHoldStep({
|
|
4250
|
+
rowTop: row.getBoundingClientRect().top,
|
|
4251
|
+
paneTop: el.getBoundingClientRect().top,
|
|
4252
|
+
scrollTop: el.scrollTop,
|
|
4253
|
+
seen,
|
|
4254
|
+
});
|
|
4255
|
+
if (step.action === 'release') {
|
|
4256
|
+
releaseReadingAnchorHold();
|
|
4257
|
+
return;
|
|
4258
|
+
}
|
|
4259
|
+
if (step.action === 'correct') el.scrollTop += step.delta;
|
|
4260
|
+
seen.rowContentTop = step.rowContentTop;
|
|
4261
|
+
// Read back: the browser can clamp the correction.
|
|
4262
|
+
seen.scrollTop = el.scrollTop;
|
|
4263
|
+
};
|
|
4264
|
+
const onMediaSettled = () => {
|
|
4265
|
+
check();
|
|
4266
|
+
if (readingAnchorHold === hold && !pendingTranscriptImages(el).length) releaseReadingAnchorHold();
|
|
4267
|
+
};
|
|
4268
|
+
const onReaderInput = () => releaseReadingAnchorHold();
|
|
4269
|
+
// A key can scroll the pane from anywhere but a field being typed in (the
|
|
4270
|
+
// composer, a card's reply): typing there moves nothing.
|
|
4271
|
+
const onKeyDown = (event) => {
|
|
4272
|
+
const target = event?.target;
|
|
4273
|
+
if (target && (target.tagName === 'TEXTAREA' || target.tagName === 'INPUT' || target.isContentEditable === true)) return;
|
|
4274
|
+
releaseReadingAnchorHold();
|
|
4275
|
+
};
|
|
4276
|
+
const observer = typeof ResizeObserver === 'function' ? new ResizeObserver(check) : null;
|
|
4277
|
+
for (const img of pending) observer?.observe(img);
|
|
4278
|
+
el.addEventListener('load', onMediaSettled, true);
|
|
4279
|
+
el.addEventListener('error', onMediaSettled, true);
|
|
4280
|
+
// A video's box changes when its metadata is in.
|
|
4281
|
+
el.addEventListener('loadedmetadata', check, true);
|
|
4282
|
+
el.addEventListener('scroll', check, { passive: true });
|
|
4283
|
+
el.addEventListener('wheel', onReaderInput, { passive: true });
|
|
4284
|
+
el.addEventListener('touchstart', onReaderInput, { passive: true });
|
|
4285
|
+
document.addEventListener('keydown', onKeyDown, true);
|
|
4286
|
+
const timer = setTimeout(releaseReadingAnchorHold, READING_ANCHOR_HOLD_MAX_MS);
|
|
4287
|
+
const hold = {
|
|
4288
|
+
cleanup() {
|
|
4289
|
+
clearTimeout(timer);
|
|
4290
|
+
observer?.disconnect();
|
|
4291
|
+
el.removeEventListener('load', onMediaSettled, true);
|
|
4292
|
+
el.removeEventListener('error', onMediaSettled, true);
|
|
4293
|
+
el.removeEventListener('loadedmetadata', check, true);
|
|
4294
|
+
el.removeEventListener('scroll', check);
|
|
4295
|
+
el.removeEventListener('wheel', onReaderInput);
|
|
4296
|
+
el.removeEventListener('touchstart', onReaderInput);
|
|
4297
|
+
document.removeEventListener('keydown', onKeyDown, true);
|
|
4298
|
+
},
|
|
4299
|
+
};
|
|
4300
|
+
readingAnchorHold = hold;
|
|
4301
|
+
}
|
|
4302
|
+
|
|
4303
|
+
/**
|
|
4304
|
+
* The window on the page no longer meets the end of the conversation (more
|
|
4305
|
+
* was written than one page holds): newer rows load as after a jump into
|
|
4306
|
+
* the history, and the jump-to-bottom button loads the end.
|
|
4307
|
+
*/
|
|
4308
|
+
export function detachConversationWindowFromTail() {
|
|
4309
|
+
const newestMessageId = String(conversationHistoryState.newestMessageId || '').trim();
|
|
4310
|
+
setConversationHistoryState({ ...conversationHistoryState, hasMoreNewer: true });
|
|
4311
|
+
// A live bubble on the page now sits under a window that ends above the
|
|
4312
|
+
// end, below rows that are not loaded: it goes until the window meets the
|
|
4313
|
+
// end again (showThinking draws none meanwhile).
|
|
4314
|
+
takeThinkingBubbleOff();
|
|
4315
|
+
conversationFutureLoader.reset({
|
|
4316
|
+
hasMore: true,
|
|
4317
|
+
nextCursor: {
|
|
4318
|
+
afterMessageId: newestMessageId || null,
|
|
4319
|
+
afterTimestamp: String(conversationHistoryState.newestMessageTimestamp || '').trim() || null,
|
|
4320
|
+
},
|
|
4321
|
+
});
|
|
4322
|
+
}
|
|
4323
|
+
|
|
4324
|
+
// One message as the transcript's snapshot key sees it.
|
|
4325
|
+
function messageRenderKey(message) {
|
|
4326
|
+
return buildMessageSnapshotKey([message], { conversationId: '', hasMoreOlder: false, hasMoreNewer: false });
|
|
4327
|
+
}
|
|
4328
|
+
|
|
4329
|
+
/**
|
|
4330
|
+
* Whether the transcript on the page can take a merge of `previousMessages`'
|
|
4331
|
+
* window (mergeMessagesIntoTranscript) rather than a full render: not while
|
|
4332
|
+
* a bubble still waits for its send, the status view is up, a selection is
|
|
4333
|
+
* held, another conversation's rows are on the page, or a row of the window
|
|
4334
|
+
* is missing from it (what is merged in is placed next to those rows, and a
|
|
4335
|
+
* changed one is rebuilt where it stands).
|
|
4336
|
+
*/
|
|
4337
|
+
export function canMergeIntoTranscript(previousMessages, conversationId) {
|
|
4338
|
+
const el = getMessagesElement();
|
|
4339
|
+
const id = String(conversationId || '').trim();
|
|
4340
|
+
if (!el || !id) return false;
|
|
4341
|
+
if (isChatInteractionHeld()
|
|
4342
|
+
|| el.querySelector('.status-view')
|
|
4343
|
+
|| getRenderedConversationId() !== id
|
|
4344
|
+
|| hasPendingUserMessageForConversation(id)) {
|
|
4345
|
+
return false;
|
|
4346
|
+
}
|
|
4347
|
+
const rendered = new Set(Array.from(readingAnchorRows(el), (node) => String(node.dataset.messageId || '').trim()));
|
|
4348
|
+
for (const message of previousMessages || []) {
|
|
4349
|
+
const messageId = String(message?.id || '').trim();
|
|
4350
|
+
if (messageId && !rendered.has(messageId)) return false;
|
|
4351
|
+
}
|
|
4352
|
+
return true;
|
|
4353
|
+
}
|
|
4354
|
+
|
|
4355
|
+
/**
|
|
4356
|
+
* Puts a merged window (mergeTailPage) on the page without rebuilding it:
|
|
4357
|
+
* the rows the newest page changed are rebuilt where they stand, its new ones
|
|
4358
|
+
* are put in their place, the rows it dropped are taken off, every other row
|
|
4359
|
+
* stays as it is under the reader.
|
|
4360
|
+
* Falls back to a full render when the page holds rows the merge cannot
|
|
4361
|
+
* place (a bubble still waiting for its send, the status view, a held
|
|
4362
|
+
* selection). Returns true when the transcript changed.
|
|
4363
|
+
*/
|
|
4364
|
+
export function mergeMessagesIntoTranscript(previousMessages, msgs, meta = {}) {
|
|
4365
|
+
const el = getMessagesElement();
|
|
4366
|
+
if (!el) return false;
|
|
4367
|
+
const conversationId = String(meta.conversationId || currentConvId || '').trim();
|
|
4368
|
+
if (!canMergeIntoTranscript(previousMessages, conversationId)) return renderMessages(msgs, false, meta);
|
|
4369
|
+
const ordered = sortConversationMessages(msgs || []);
|
|
4370
|
+
const snapshotKey = buildMessageSnapshotKey(ordered, meta);
|
|
4371
|
+
if (snapshotKey === lastRenderedMessageSnapshotKey) return false;
|
|
4372
|
+
const previousById = new Map((previousMessages || []).map((m) => [String(m?.id || '').trim(), m]));
|
|
4373
|
+
const messageById = new Map(ordered.map((m) => [String(m?.id || '').trim(), m]).filter(([id]) => !!id));
|
|
4374
|
+
let previousId = '';
|
|
4375
|
+
withSuspendedSeparatorSync(() => {
|
|
4376
|
+
// A window row the merge left out was deleted while the reader was away
|
|
4377
|
+
// (mergeTailPage drops it): it leaves the page too. Taken off first, so
|
|
4378
|
+
// nothing merged in is placed next to it.
|
|
4379
|
+
for (const id of previousById.keys()) {
|
|
4380
|
+
if (!id || messageById.has(id)) continue;
|
|
4381
|
+
el.querySelector(`:scope > .msg[data-message-id="${CSS.escape(id)}"]:not(#thinking-indicator)`)?.remove();
|
|
4382
|
+
seenMessageIds.delete(id);
|
|
4383
|
+
}
|
|
4384
|
+
for (const m of ordered) {
|
|
4385
|
+
const id = String(m?.id || '').trim();
|
|
4386
|
+
if (!id) continue;
|
|
4387
|
+
const before = previousById.get(id);
|
|
4388
|
+
if (!before) {
|
|
4389
|
+
const threadAnchor = getMessageThreadAnchor(m, messageById);
|
|
4390
|
+
appendMessage(m, false, id, true, threadAnchor !== id ? threadAnchor : (previousId || null), false);
|
|
4391
|
+
} else if (messageRenderKey(before) !== messageRenderKey(m)) {
|
|
4392
|
+
replaceRenderedMessage(m, id);
|
|
4393
|
+
}
|
|
4394
|
+
previousId = id;
|
|
4395
|
+
}
|
|
4396
|
+
});
|
|
4397
|
+
const newest = ordered[ordered.length - 1] || null;
|
|
4398
|
+
setConversationHistoryState({
|
|
4399
|
+
...conversationHistoryState,
|
|
4400
|
+
conversationId,
|
|
4401
|
+
newestMessageId: String(newest?.id || conversationHistoryState.newestMessageId || '').trim(),
|
|
4402
|
+
newestMessageTimestamp: String(newest?.timestamp || conversationHistoryState.newestMessageTimestamp || '').trim(),
|
|
4403
|
+
loadedMessageCount: ordered.length,
|
|
4404
|
+
});
|
|
4405
|
+
syncSeparatorsNow();
|
|
4406
|
+
renderRelayQuestions();
|
|
4407
|
+
renderRelayBoards();
|
|
4408
|
+
lastRenderedMessageSnapshotKey = snapshotKey;
|
|
4409
|
+
return true;
|
|
4410
|
+
}
|
|
4411
|
+
|
|
3981
4412
|
export function focusConversationMessageById(messageId, { behavior = 'smooth', block = 'center' } = {}) {
|
|
3982
4413
|
const id = String(messageId || '').trim();
|
|
3983
4414
|
if (!id) return false;
|
|
@@ -4025,6 +4456,19 @@ export function compactCurrentConversation() {
|
|
|
4025
4456
|
// edit re-arms the warning.
|
|
4026
4457
|
let unknownCommandWarned = null;
|
|
4027
4458
|
|
|
4459
|
+
// An own message goes to the end of the conversation. Sent from a window that
|
|
4460
|
+
// ends above it, its row put under the window would sit below rows that are
|
|
4461
|
+
// not loaded, and would move the newer pages' cursor past them; no live
|
|
4462
|
+
// bubble or reply would be drawn there either. So the end is loaded first,
|
|
4463
|
+
// as the jump-to-bottom button does, and the row, the bubble and the reply
|
|
4464
|
+
// follow at the end, whatever the scroll-on-send setting says: the end is
|
|
4465
|
+
// where the message is. Resolves once the end is on the page, or its load
|
|
4466
|
+
// failed (then the window still ends above, and no row is put under it).
|
|
4467
|
+
function loadEndForOwnSend() {
|
|
4468
|
+
if (isConversationWindowAtTail()) return Promise.resolve();
|
|
4469
|
+
return jumpTranscriptToBottom();
|
|
4470
|
+
}
|
|
4471
|
+
|
|
4028
4472
|
export async function sendMessage() {
|
|
4029
4473
|
// The reader's own send, as of now: what follows it scrolls to the end
|
|
4030
4474
|
// only while they have not scrolled since.
|
|
@@ -4126,7 +4570,10 @@ export async function sendMessage() {
|
|
|
4126
4570
|
let attachments = [];
|
|
4127
4571
|
let clientMessageId = null;
|
|
4128
4572
|
try {
|
|
4573
|
+
// Alongside the upload: the end is loaded while the files go up.
|
|
4574
|
+
const endLoaded = viewingSendConversation() ? loadEndForOwnSend() : null;
|
|
4129
4575
|
attachments = await uploadAttachments(draftAttachments);
|
|
4576
|
+
await endLoaded;
|
|
4130
4577
|
|
|
4131
4578
|
const isNew = !targetConversationId;
|
|
4132
4579
|
const msgTimestamp = new Date().toISOString();
|
|
@@ -4148,7 +4595,11 @@ export async function sendMessage() {
|
|
|
4148
4595
|
releaseComposerFocusAfterSend(input);
|
|
4149
4596
|
// Asked before the bubble is on the page.
|
|
4150
4597
|
const follow = ownSendFollows(send);
|
|
4151
|
-
|
|
4598
|
+
// Not under a window that still ends above the end (loadEndForOwnSend
|
|
4599
|
+
// failed): the row comes with the end.
|
|
4600
|
+
if (isConversationWindowAtTail()) {
|
|
4601
|
+
appendMessage({ role: 'user', text, model: selectedModel, mode: selectedMode, timestamp: msgTimestamp, attachments }, follow, clientMessageId, true);
|
|
4602
|
+
}
|
|
4152
4603
|
scrollAfterOwnSend(send);
|
|
4153
4604
|
}
|
|
4154
4605
|
|
|
@@ -4172,7 +4623,10 @@ export async function sendMessage() {
|
|
|
4172
4623
|
: undefined,
|
|
4173
4624
|
};
|
|
4174
4625
|
|
|
4175
|
-
|
|
4626
|
+
// A refusal the user can act on (the conversation's model left the
|
|
4627
|
+
// catalog) is shown as the relay words it.
|
|
4628
|
+
let refusal = '';
|
|
4629
|
+
const r = await sendMessageApi(body, { onRefused: (reason) => { refusal = reason; } });
|
|
4176
4630
|
if (!r) {
|
|
4177
4631
|
// Offline: park the send in the durable outbox instead of bouncing it
|
|
4178
4632
|
// back into the composer. The client-generated messageId makes a replay
|
|
@@ -4206,7 +4660,7 @@ export async function sendMessage() {
|
|
|
4206
4660
|
sendLanded,
|
|
4207
4661
|
});
|
|
4208
4662
|
if (!mobileSend && viewingSendConversation()) input.focus();
|
|
4209
|
-
setModelBanner('⚠️ Message could not be sent. Please try again.');
|
|
4663
|
+
setModelBanner(refusal ? `⚠️ ${refusal}` : '⚠️ Message could not be sent. Please try again.');
|
|
4210
4664
|
return;
|
|
4211
4665
|
}
|
|
4212
4666
|
|