@oar-sh/oar 0.9.10 → 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 +440 -1
- package/README.md +156 -972
- package/package.json +13 -13
- package/server/README.md +112 -25
- 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 +217 -11
- package/server/claude-worker/claude-session-worker.mjs +26 -1
- package/server/claude-worker/task-output-discovery.mjs +126 -0
- package/server/copilot-worker/copilot-sdk-session-process.mjs +235 -14
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +8 -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 +68 -2
- package/server/grok-worker/acp-client.mjs +2 -1
- package/server/grok-worker/acp-host-services.mjs +46 -6
- 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/0013-previews.mjs +130 -0
- package/server/migrations/0014-conversation-groups.mjs +89 -0
- package/server/migrations/0015-conversation-group-position.mjs +76 -0
- package/server/migrations/0016-share-access-log.mjs +110 -0
- package/server/migrations/0017-conversation-history-indexes.mjs +89 -0
- package/server/public/app/about-ui.js +73 -0
- package/server/public/app/about-view.mjs +234 -0
- package/server/public/app/action-confirmations.js +31 -14
- package/server/public/app/agent-exchange-fold-view.mjs +40 -101
- package/server/public/app/api-client.js +264 -16
- package/server/public/app/ask-user-view.js +17 -15
- package/server/public/app/background-tasks-view.mjs +285 -16
- package/server/public/app/bootstrap.js +253 -37
- package/server/public/app/chat-actions-menu-state.mjs +20 -10
- package/server/public/app/claude-background-command-limit-options.mjs +21 -0
- package/server/public/app/context-menu.mjs +90 -6
- package/server/public/app/conversation-group-drag.mjs +191 -0
- package/server/public/app/conversation-group-order.mjs +265 -0
- package/server/public/app/conversation-group-state.mjs +100 -0
- package/server/public/app/conversation-list-drag.mjs +449 -0
- package/server/public/app/conversation-payload-cache.mjs +357 -0
- package/server/public/app/conversation-turn-state.mjs +81 -0
- package/server/public/app/conversation-view.js +567 -94
- package/server/public/app/conversation-window-retention.mjs +96 -0
- package/server/public/app/cwd-picker.js +1 -1
- package/server/public/app/hidden-messages-view.mjs +312 -0
- package/server/public/app/host-suspend-ui.js +7 -1
- package/server/public/app/journal-view.js +1033 -82
- package/server/public/app/preview-cards.mjs +117 -31
- package/server/public/app/preview-command.mjs +10 -1
- package/server/public/app/relay-board-view.js +19 -6
- package/server/public/app/relay-prompt-sanitizer.mjs +11 -4
- package/server/public/app/relay-restart-error.mjs +16 -0
- package/server/public/app/settings-modal.js +150 -10
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/share-format.mjs +49 -0
- package/server/public/app/share-preview-marks.mjs +167 -0
- package/server/public/app/share-state.js +109 -0
- package/server/public/app/shared-conversations-settings.js +480 -0
- package/server/public/app/socket-handlers.js +138 -20
- package/server/public/app/store.js +121 -30
- package/server/public/app/subagent-row.mjs +3 -3
- package/server/public/app/subagent-viewer.mjs +37 -7
- 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/thinking-follow.js +0 -4
- package/server/public/app/transcript-island.mjs +73 -0
- package/server/public/app/transcript-jump-bottom-view.mjs +230 -26
- package/server/public/app/transcript-jump-bottom.mjs +170 -22
- 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 +159 -0
- package/server/public/index.html +625 -21
- package/server/relay-tools.md +3 -1
- package/server/relay.mjs +2 -1
- package/server/repositories/conversation-group-repository.mjs +444 -0
- package/server/repositories/message-repository.mjs +9 -0
- package/server/repositories/question-repository.mjs +4 -0
- package/server/repositories/session-repository.mjs +12 -37
- package/server/repositories/share-repository.mjs +322 -0
- package/server/repositories/share-visibility-statements.mjs +10 -0
- package/server/routes/about-routes.mjs +15 -0
- package/server/routes/cache-routes.mjs +4 -0
- package/server/routes/conversation-group-routes.mjs +171 -0
- package/server/routes/messages-routes.mjs +433 -68
- package/server/routes/preview-routes.mjs +9 -1
- package/server/routes/sessions-routes.mjs +901 -253
- package/server/server-runtime.mjs +392 -99
- package/server/services/about-service.mjs +461 -0
- package/server/services/background-task-store.mjs +365 -0
- package/server/services/background-work-view.mjs +228 -0
- package/server/services/cache-rebuild-service.mjs +30 -0
- package/server/services/cli-process-runner.mjs +4 -21
- package/server/services/conversation-history-page.mjs +199 -0
- package/server/services/delete-archive-service.mjs +4 -1
- package/server/services/detached-worker-watch-service.mjs +150 -0
- package/server/services/hidden-message-list.mjs +21 -0
- package/server/services/host-suspend-command.mjs +2 -1
- package/server/services/message-pin-list.mjs +1 -1
- package/server/services/preview-health-probe.mjs +8 -1
- package/server/services/preview-proxy-server.mjs +8 -4
- package/server/services/preview-registry-service.mjs +676 -26
- package/server/services/preview-static-handler.mjs +40 -7
- package/server/services/preview-store.mjs +274 -0
- package/server/services/rate-limit-service.mjs +12 -1
- package/server/services/relay-auth.mjs +183 -0
- package/server/services/relay-cli-launcher-service.mjs +3 -1
- package/server/services/relay-event-service.mjs +17 -0
- package/server/services/relay-prompt-sanitizer.mjs +11 -4
- package/server/services/remote-relay-dispatcher.mjs +187 -7
- package/server/services/request-boundary.mjs +183 -0
- package/server/services/session-activity-tracker.mjs +231 -19
- package/server/services/session-worker-launch-service.mjs +190 -6
- package/server/services/session-worker-process-service.mjs +10 -6
- package/server/services/share-access-log-service.mjs +123 -0
- package/server/services/share-path-redaction.mjs +424 -0
- package/server/services/status-event-service.mjs +7 -3
- package/server/services/task-output-rooms.mjs +259 -0
- package/server/services/task-output-service.mjs +562 -0
- package/server/services/tunnel-worker-path-guard.mjs +24 -41
- package/server/services/windows-autostart-service.mjs +6 -1
- package/server/services/windows-boot-autostart-service.mjs +207 -23
- package/shared/claude-background-command-limit.mjs +83 -0
- package/shared/preview-tool-core.mjs +15 -5
- package/shared/remote-relay-contract.mjs +35 -3
- package/shared/share-path-privacy.mjs +30 -0
- package/shared/task-output-text.mjs +737 -0
- package/shared/windows-system-tools.mjs +106 -0
- package/shared/worker-runtime/background-task-digest.mjs +30 -0
- package/shared/worker-runtime/heartbeat.mjs +24 -0
- package/shared/worker-runtime/kept-posts.mjs +83 -0
- package/shared/worker-runtime/task-output-spool.mjs +181 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +47 -15
|
@@ -23,7 +23,6 @@ import {
|
|
|
23
23
|
updateWorkspaceRootHints,
|
|
24
24
|
applyContextUsageBar,
|
|
25
25
|
scrollBottom,
|
|
26
|
-
scrollBottomAfterSend,
|
|
27
26
|
isMobileComposerViewport,
|
|
28
27
|
releaseComposerFocusAfterSend,
|
|
29
28
|
autoResize,
|
|
@@ -47,10 +46,9 @@ import {
|
|
|
47
46
|
getSessionWorkerState,
|
|
48
47
|
openSummaryModal,
|
|
49
48
|
closeSummaryModal,
|
|
50
|
-
parseTimestampMs,
|
|
51
|
-
relayQuestions,
|
|
52
|
-
relayBoards,
|
|
53
49
|
toggleSidebar,
|
|
50
|
+
hasPendingUserMessageForConversation,
|
|
51
|
+
isMessagesNearBottom,
|
|
54
52
|
} from './store.js';
|
|
55
53
|
import { getPendingQuestionCountsByConversation } from './ask-user-view.js';
|
|
56
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';
|
|
@@ -62,6 +60,7 @@ import { buildWorkflowRunCard } from './background-tasks-view.mjs';
|
|
|
62
60
|
import { mergeLiveSubagentRun, subagentRowsMarkup } from './subagent-row.mjs';
|
|
63
61
|
import { rememberSubagentRuns, syncSubagentRowClock } from './subagent-viewer.mjs';
|
|
64
62
|
import { isMessagePinned, toggleMessagePin } from './pinned-messages-view.mjs';
|
|
63
|
+
import { isMessageHiddenFromShares, setConversationHiddenMessages } from './hidden-messages-view.mjs';
|
|
65
64
|
import { parsePreviewCommand, runPreviewCommand } from './preview-command.mjs';
|
|
66
65
|
import { buildTranscriptPreviewCard } from './preview-cards.mjs';
|
|
67
66
|
import { closeSlashAutocomplete, handleSlashAutocompleteKey, updateSlashAutocomplete } from './slash-autocomplete.mjs';
|
|
@@ -71,7 +70,6 @@ import { isRelayEventMessage, renderRelayEventRowHtml } from './relay-event-view
|
|
|
71
70
|
import {
|
|
72
71
|
AGENT_EXCHANGE_CLOSED_CLASS,
|
|
73
72
|
AGENT_EXCHANGE_HEAD_CLASS,
|
|
74
|
-
AGENT_EXCHANGE_MEMBER_CLASS,
|
|
75
73
|
agentExchangeFoldOpen,
|
|
76
74
|
agentExchangeRowOf,
|
|
77
75
|
agentExchangeSummary,
|
|
@@ -135,6 +133,8 @@ import {
|
|
|
135
133
|
shouldApplyIncomingDraftToComposer,
|
|
136
134
|
} from './conversation-draft-sync.mjs';
|
|
137
135
|
import { isChatInteractionHeld, selectionIntersectsNode } from './selection-guard.mjs';
|
|
136
|
+
import { captureReadingAnchor, readingAnchorContentTop, readingAnchorHoldStep, readingAnchorScrollDelta } from './transcript-reading-anchor.mjs';
|
|
137
|
+
import { captureReadingBlock, restoreReadingBlock } from './transcript-viewport-anchor.mjs';
|
|
138
138
|
import {
|
|
139
139
|
applyThinkingFollow,
|
|
140
140
|
getThinkingFollowMode,
|
|
@@ -145,9 +145,12 @@ import {
|
|
|
145
145
|
toggleThinkingFollowMode,
|
|
146
146
|
unobserveThinkingBubble,
|
|
147
147
|
} from './thinking-follow.js';
|
|
148
|
+
import { beginOwnSend, followsTranscriptEnd, jumpTranscriptToBottom, nameOwnSendConversation, ownSendFollows, refreshJumpButton, scrollAfterOwnSend } from './transcript-jump-bottom-view.mjs';
|
|
149
|
+
import { createHeldBackLiveRows, newerPageReachesEnd } from './transcript-island.mjs';
|
|
148
150
|
import { buildInFlightSnapshotKey } from './in-flight-snapshot.mjs';
|
|
149
151
|
import { computeStablePrefixLength, planListPatch } from './streaming-dom-patch.mjs';
|
|
150
152
|
import { prefixToolActivityEmoji } from './tool-activity-emoji.mjs';
|
|
153
|
+
import { conversationPayloadCache } from './conversation-payload-cache.mjs';
|
|
151
154
|
|
|
152
155
|
const CONVERSATION_HISTORY_PAGE_SIZE = 20;
|
|
153
156
|
const HISTORY_LOAD_MORE_ID = 'history-load-more';
|
|
@@ -241,6 +244,20 @@ let conversationHistoryState = {
|
|
|
241
244
|
loadingOlder: false,
|
|
242
245
|
loadingNewer: false,
|
|
243
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
|
+
}
|
|
244
261
|
|
|
245
262
|
function renderImageEditTarget() {
|
|
246
263
|
const chip = document.getElementById('image-edit-target');
|
|
@@ -356,6 +373,8 @@ const conversationHistoryLoader = createInfiniteLoader({
|
|
|
356
373
|
const previousScrollTop = el.scrollTop;
|
|
357
374
|
const previousScrollHeight = el.scrollHeight;
|
|
358
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 });
|
|
359
378
|
setConversationHistoryState({
|
|
360
379
|
conversationId: currentId,
|
|
361
380
|
hasMoreOlder: page.hasMore,
|
|
@@ -383,6 +402,10 @@ const conversationHistoryLoader = createInfiniteLoader({
|
|
|
383
402
|
if (!el || String(currentConvId || '').trim() !== currentId) return;
|
|
384
403
|
const nextScrollHeight = el.scrollHeight;
|
|
385
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);
|
|
386
409
|
void conversationHistoryLoader.handleBoundaryDistance(el.scrollTop);
|
|
387
410
|
});
|
|
388
411
|
},
|
|
@@ -429,6 +452,24 @@ const conversationFutureLoader = createInfiniteLoader({
|
|
|
429
452
|
const currentId = String(currentConvId || '').trim();
|
|
430
453
|
if (!currentId || conversationPaginationCursorIsStale(currentId)) return;
|
|
431
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 });
|
|
432
473
|
let inserted = 0;
|
|
433
474
|
withSuspendedSeparatorSync(() => {
|
|
434
475
|
for (const m of ordered) {
|
|
@@ -450,6 +491,9 @@ const conversationFutureLoader = createInfiniteLoader({
|
|
|
450
491
|
loadingOlder: conversationHistoryState.loadingOlder,
|
|
451
492
|
loadingNewer: false,
|
|
452
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();
|
|
453
497
|
},
|
|
454
498
|
onError: (error, { mode }) => {
|
|
455
499
|
if (mode === 'load') {
|
|
@@ -462,6 +506,7 @@ const conversationFutureLoader = createInfiniteLoader({
|
|
|
462
506
|
hasMoreNewer: state.hasMore,
|
|
463
507
|
loadingNewer: state.isLoading,
|
|
464
508
|
};
|
|
509
|
+
syncHeldBackLiveRows();
|
|
465
510
|
},
|
|
466
511
|
});
|
|
467
512
|
|
|
@@ -1179,6 +1224,7 @@ function resetConversationHistoryState() {
|
|
|
1179
1224
|
conversationFutureLoader.reset({ hasMore: false, nextCursor: null });
|
|
1180
1225
|
saveConversationLoadedMessageCount(conversationId, 0);
|
|
1181
1226
|
syncHistoryLoadMoreControl();
|
|
1227
|
+
syncHeldBackLiveRows();
|
|
1182
1228
|
}
|
|
1183
1229
|
|
|
1184
1230
|
function setConversationHistoryState(next = {}) {
|
|
@@ -1199,6 +1245,7 @@ function setConversationHistoryState(next = {}) {
|
|
|
1199
1245
|
conversationHistoryState.loadedMessageCount,
|
|
1200
1246
|
);
|
|
1201
1247
|
syncHistoryLoadMoreControl();
|
|
1248
|
+
syncHeldBackLiveRows();
|
|
1202
1249
|
}
|
|
1203
1250
|
|
|
1204
1251
|
function getConversationHistoryCursor() {
|
|
@@ -1289,38 +1336,20 @@ function createRelayEventNode(msg, msgId) {
|
|
|
1289
1336
|
return div;
|
|
1290
1337
|
}
|
|
1291
1338
|
|
|
1292
|
-
// Agent exchanges (agent-exchange-fold-view.mjs): an agent's prompt
|
|
1293
|
-
// session's reply
|
|
1294
|
-
// kept on its node when the row is built, so the pass
|
|
1295
|
-
// itself and not the payload it came from.
|
|
1339
|
+
// Agent exchanges (agent-exchange-fold-view.mjs): an agent's prompt folds
|
|
1340
|
+
// into one row; this session's reply to it always shows. What the fold pass
|
|
1341
|
+
// needs of a message is kept on its node when the row is built, so the pass
|
|
1342
|
+
// reads the transcript itself and not the payload it came from.
|
|
1296
1343
|
const AGENT_EXCHANGE_ROW_KEY = '__agentExchangeRow';
|
|
1297
|
-
// The user's toggle of
|
|
1344
|
+
// The user's toggle of a folded prompt, by its id: true opened, false
|
|
1298
1345
|
// closed, unset none (agentExchangeFoldOpen: open while it works, else
|
|
1299
1346
|
// folded). Kept through any re-render.
|
|
1300
1347
|
const agentExchangeFoldChoices = new Map();
|
|
1301
|
-
//
|
|
1302
|
-
// card, or ran while a message of the user's waited, stays shown once that
|
|
1303
|
-
// has passed instead of folding away under them. A reload folds it again.
|
|
1304
|
-
const agentExchangesShownForUser = new Set();
|
|
1305
|
-
// Each folded row's prompt id, by message id, and each folded exchange's
|
|
1306
|
-
// status, by prompt id, as of the last pass.
|
|
1307
|
-
let agentExchangePromptIdByMessageId = new Map();
|
|
1348
|
+
// Each folded prompt's status, by its id, as of the last pass.
|
|
1308
1349
|
let agentExchangeStatusByPromptId = new Map();
|
|
1309
1350
|
|
|
1310
1351
|
function setAgentExchangeRow(div, msg, msgId) {
|
|
1311
|
-
div[AGENT_EXCHANGE_ROW_KEY] = agentExchangeRowOf(msg, { id: msgId || msg?.id || ''
|
|
1312
|
-
}
|
|
1313
|
-
|
|
1314
|
-
// The turns an open question or approval card (a plan board) belongs to.
|
|
1315
|
-
function openCardMessageIds() {
|
|
1316
|
-
const ids = new Set();
|
|
1317
|
-
for (const card of [...relayQuestions.values(), ...relayBoards.values()]) {
|
|
1318
|
-
if (card?.status !== 'pending') continue;
|
|
1319
|
-
for (const id of [card.messageId, card.queueId]) {
|
|
1320
|
-
if (id) ids.add(String(id).trim());
|
|
1321
|
-
}
|
|
1322
|
-
}
|
|
1323
|
-
return ids;
|
|
1352
|
+
div[AGENT_EXCHANGE_ROW_KEY] = agentExchangeRowOf(msg, { id: msgId || msg?.id || '' });
|
|
1324
1353
|
}
|
|
1325
1354
|
|
|
1326
1355
|
// The rows the plan reads, in order, and the node of each. The live bubble
|
|
@@ -1345,58 +1374,45 @@ function agentExchangeTranscriptRows(el) {
|
|
|
1345
1374
|
}
|
|
1346
1375
|
|
|
1347
1376
|
/**
|
|
1348
|
-
* Fold the transcript's agent
|
|
1377
|
+
* Fold the transcript's agent prompts. One idempotent pass, run after every
|
|
1349
1378
|
* render and update of the list like the separator and merge passes. The
|
|
1350
1379
|
* folded row lives in the prompt's own node, as its first child, so no
|
|
1351
|
-
* insertion path has to place it; closed,
|
|
1352
|
-
* the
|
|
1380
|
+
* insertion path has to place it; closed, a class hides the prompt's bubble.
|
|
1381
|
+
* The replies and the live bubble are read for the row's status and are
|
|
1382
|
+
* otherwise left alone.
|
|
1353
1383
|
*/
|
|
1354
1384
|
function syncAgentExchangeFolds(el = getMessagesElement()) {
|
|
1355
1385
|
if (!el) return;
|
|
1356
1386
|
const { rows, nodes } = agentExchangeTranscriptRows(el);
|
|
1357
1387
|
const plan = planAgentExchangeFolds(rows, {
|
|
1358
1388
|
runningIds: [getActiveTurnForConversation(currentConvId)?.messageId],
|
|
1359
|
-
openCardIds: openCardMessageIds(),
|
|
1360
|
-
keptShownIds: agentExchangesShownForUser,
|
|
1361
1389
|
});
|
|
1362
1390
|
const states = new Map();
|
|
1363
|
-
const promptIdByMessageId = new Map();
|
|
1364
1391
|
const statusByPromptId = new Map();
|
|
1365
1392
|
for (const group of plan) {
|
|
1366
|
-
if (group.transient) agentExchangesShownForUser.add(group.promptId);
|
|
1367
1393
|
if (!group.fold) continue;
|
|
1368
1394
|
const open = agentExchangeFoldOpen(group.status, agentExchangeFoldChoices.get(group.promptId));
|
|
1369
|
-
states.set(nodes.get(group.prompt), { group,
|
|
1370
|
-
promptIdByMessageId.set(group.promptId, group.promptId);
|
|
1395
|
+
states.set(nodes.get(group.prompt), { group, open });
|
|
1371
1396
|
statusByPromptId.set(group.promptId, group.status);
|
|
1372
|
-
for (const member of group.members) {
|
|
1373
|
-
states.set(nodes.get(member), { group, head: false, open });
|
|
1374
|
-
promptIdByMessageId.set(member.id, group.promptId);
|
|
1375
|
-
}
|
|
1376
1397
|
}
|
|
1377
|
-
agentExchangePromptIdByMessageId = promptIdByMessageId;
|
|
1378
1398
|
agentExchangeStatusByPromptId = statusByPromptId;
|
|
1379
1399
|
for (const node of el.children) {
|
|
1380
1400
|
if (node.classList?.contains('msg')) applyAgentExchangeState(node, states.get(node) || null);
|
|
1381
1401
|
}
|
|
1382
1402
|
}
|
|
1383
1403
|
|
|
1384
|
-
// For the paths that change the list outside a render: the live bubble
|
|
1385
|
-
//
|
|
1404
|
+
// For the paths that change the list outside a render: the live bubble
|
|
1405
|
+
// coming and going. A bulk render runs the pass once at its end.
|
|
1386
1406
|
export function syncAgentExchangeFoldsNow() {
|
|
1387
1407
|
if (separatorSyncSuspended > 0) return;
|
|
1388
1408
|
syncAgentExchangeFolds();
|
|
1389
1409
|
}
|
|
1390
1410
|
|
|
1391
1411
|
function applyAgentExchangeState(node, state) {
|
|
1392
|
-
node.classList.toggle(AGENT_EXCHANGE_HEAD_CLASS, !!state
|
|
1393
|
-
node.classList.toggle(AGENT_EXCHANGE_MEMBER_CLASS, !!state && !state.head);
|
|
1412
|
+
node.classList.toggle(AGENT_EXCHANGE_HEAD_CLASS, !!state);
|
|
1394
1413
|
node.classList.toggle(AGENT_EXCHANGE_CLOSED_CLASS, !!state && !state.open);
|
|
1395
|
-
// The jump-to-bottom badge counts a fold's rows as one by this id.
|
|
1396
|
-
if (state) node.dataset.agentExchangePromptId = state.group.promptId;
|
|
1397
|
-
else delete node.dataset.agentExchangePromptId;
|
|
1398
1414
|
const existing = node.querySelector(':scope > .agent-exchange-row');
|
|
1399
|
-
if (!state
|
|
1415
|
+
if (!state) {
|
|
1400
1416
|
existing?.remove();
|
|
1401
1417
|
return;
|
|
1402
1418
|
}
|
|
@@ -1430,11 +1446,11 @@ function onAgentExchangeToggle(row) {
|
|
|
1430
1446
|
syncAgentExchangeFolds();
|
|
1431
1447
|
}
|
|
1432
1448
|
|
|
1433
|
-
// A search result, a pin or a jump
|
|
1434
|
-
//
|
|
1449
|
+
// A search result, a pin or a jump to a folded prompt opens it first, so
|
|
1450
|
+
// the message jumped to is the one on screen.
|
|
1435
1451
|
function revealAgentExchangeMessage(messageId) {
|
|
1436
|
-
const promptId =
|
|
1437
|
-
if (!promptId || agentExchangeFoldChoices.get(promptId) === true) return;
|
|
1452
|
+
const promptId = String(messageId || '').trim();
|
|
1453
|
+
if (!agentExchangeStatusByPromptId.has(promptId) || agentExchangeFoldChoices.get(promptId) === true) return;
|
|
1438
1454
|
agentExchangeFoldChoices.set(promptId, true);
|
|
1439
1455
|
syncAgentExchangeFolds();
|
|
1440
1456
|
}
|
|
@@ -1523,10 +1539,11 @@ function createMessageNode(msg, msgId = null, force = false) {
|
|
|
1523
1539
|
// (e.g. the Copilot relay answering a Cursor conversation) must be visible,
|
|
1524
1540
|
// not silent: it ran on another plan than the header indicates.
|
|
1525
1541
|
const executedProvider = String(msg?.executedProvider || '').trim().toLowerCase();
|
|
1526
|
-
const boundProvider = String(conversations[currentConvId]?.runtimeProviderType || '
|
|
1542
|
+
const boundProvider = String(conversations[currentConvId]?.runtimeProviderType || '').trim().toLowerCase();
|
|
1527
1543
|
// 'unknown' means the responder identity did not resolve, not that another
|
|
1528
|
-
// provider ran the turn — no chip for it.
|
|
1529
|
-
|
|
1544
|
+
// provider ran the turn — no chip for it. Nor without a bound provider to
|
|
1545
|
+
// compare with (a shared view of a conversation that has none).
|
|
1546
|
+
const crossProviderTag = (msg.role === 'assistant' && executedProvider && executedProvider !== 'unknown' && boundProvider && executedProvider !== boundProvider)
|
|
1530
1547
|
? ` <span class="msg-provider-mismatch" title="This turn was executed by the ${escHtml(executedProvider)} provider, not the conversation's ${escHtml(boundProvider)} provider.">ran on ${escHtml(executedProvider)}</span>`
|
|
1531
1548
|
: '';
|
|
1532
1549
|
const usage = (msg.role === 'assistant' && msg?.usage && typeof msg.usage === 'object') ? msg.usage : null;
|
|
@@ -1585,7 +1602,7 @@ function createMessageNode(msg, msgId = null, force = false) {
|
|
|
1585
1602
|
rememberSubagentRuns(subagentRuns);
|
|
1586
1603
|
const subagentHtml = subagentRowsMarkup(
|
|
1587
1604
|
subagentRuns.map((run) => mergeLiveSubagentRun(run, getSubagentRun(run.subagentRunId))),
|
|
1588
|
-
{ clickable:
|
|
1605
|
+
{ clickable: true },
|
|
1589
1606
|
);
|
|
1590
1607
|
// Finished background workflows persisted with this assistant message
|
|
1591
1608
|
// (docs/plans/workflow-progress-tree.md, Phase 4). The template only
|
|
@@ -1614,7 +1631,9 @@ function createMessageNode(msg, msgId = null, force = false) {
|
|
|
1614
1631
|
&& cancellableSteerIdsForConversation(currentConvId).has(msgId);
|
|
1615
1632
|
const showsCancelButton = isQueuedUserMessage || isCancellableSteer;
|
|
1616
1633
|
const isCancelInFlight = showsCancelButton && bubbleCancelInFlight.has(msgId);
|
|
1617
|
-
|
|
1634
|
+
// The conversation's hidden list once it is known (a change on another
|
|
1635
|
+
// device arrives there), else the message's own mark.
|
|
1636
|
+
const hiddenFromShares = isMessageHiddenFromShares(currentConvId, msgId) ?? (msg?.hiddenFromShares === true);
|
|
1618
1637
|
const activeTurnMessageId = String(getActiveTurnForConversation(currentConvId)?.messageId || '').trim();
|
|
1619
1638
|
const sourceMessageId = String(msg?.sourceMessageId || '').trim();
|
|
1620
1639
|
const belongsToActiveTurn = !!activeTurnMessageId
|
|
@@ -1627,8 +1646,8 @@ function createMessageNode(msg, msgId = null, force = false) {
|
|
|
1627
1646
|
const shareVisibilityActionHtml = canToggleShareVisibility
|
|
1628
1647
|
? `<div class="msg-share-visibility">
|
|
1629
1648
|
${buildMessagePinControlsHtml(msgId, pinned)}
|
|
1630
|
-
${hiddenFromShares ?
|
|
1631
|
-
<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
|
|
1649
|
+
${hiddenFromShares ? HIDDEN_LABEL_HTML : ''}
|
|
1650
|
+
<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="${shareVisibilityButtonTitle(hiddenFromShares)}">${hiddenFromShares ? 'Unhide' : 'Hide'}</button>
|
|
1632
1651
|
</div>`
|
|
1633
1652
|
: '';
|
|
1634
1653
|
// A terminal failure whose fix lives in the relay UI gets the fix as a button
|
|
@@ -1763,21 +1782,12 @@ function prependMessageNodes(msgs) {
|
|
|
1763
1782
|
return { inserted, firstMessageId };
|
|
1764
1783
|
}
|
|
1765
1784
|
|
|
1785
|
+
// A shared view's tool lines arrive filtered by the relay's path privacy
|
|
1786
|
+
// setting (server/services/share-path-redaction.mjs); they are shown as sent.
|
|
1766
1787
|
export function decorateActivityText(text) {
|
|
1767
1788
|
const value = String(text || '').trim();
|
|
1768
1789
|
if (!value) return '';
|
|
1769
|
-
|
|
1770
|
-
const source = String(input || '');
|
|
1771
|
-
const tokenMasked = source.replace(/@(file|folder):([^\s`]+)/gi, (_m, kind, rawPath) => {
|
|
1772
|
-
const normalized = String(rawPath || '').replace(/\\/g, '/').replace(/\/+$/, '');
|
|
1773
|
-
const segments = normalized.split('/').filter(Boolean);
|
|
1774
|
-
const basename = segments[segments.length - 1] || normalized;
|
|
1775
|
-
return `@${String(kind || '').toLowerCase()}:${basename}`;
|
|
1776
|
-
});
|
|
1777
|
-
return tokenMasked.replace(/([A-Za-z]:)?(?:[\\/~.]?[\\/])(?:[^\\/\s]+[\\/])+([^\\/\s]+)/g, (_m, _prefix, basename) => basename);
|
|
1778
|
-
};
|
|
1779
|
-
const sharedSafeValue = IS_SHARED_VIEW ? maskSharedPathSegments(value) : value;
|
|
1780
|
-
return prefixToolActivityEmoji(sharedSafeValue);
|
|
1790
|
+
return prefixToolActivityEmoji(value);
|
|
1781
1791
|
}
|
|
1782
1792
|
|
|
1783
1793
|
export function renderThoughtsMarkup(thoughts) {
|
|
@@ -1895,8 +1905,7 @@ function anchorThinkingBubble(div, messageId) {
|
|
|
1895
1905
|
if (next) el.insertBefore(div, next);
|
|
1896
1906
|
else el.appendChild(div);
|
|
1897
1907
|
if (div.dataset.compactBoundary) syncSeparatorsNow();
|
|
1898
|
-
//
|
|
1899
|
-
// the turn is working.
|
|
1908
|
+
// A folded agent prompt's row says the turn is working.
|
|
1900
1909
|
else syncAgentExchangeFoldsNow();
|
|
1901
1910
|
} else if (div.parentNode !== el) {
|
|
1902
1911
|
el.appendChild(div);
|
|
@@ -1913,6 +1922,16 @@ export function showThinking(messageId = null, autoScroll = true) {
|
|
|
1913
1922
|
const nextMessageId = String(messageId || '').trim();
|
|
1914
1923
|
if (nextMessageId) thinkingMessageId = nextMessageId;
|
|
1915
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
|
+
}
|
|
1916
1935
|
if (existing
|
|
1917
1936
|
&& existing.dataset.conversationId === thinkingConversationKey()
|
|
1918
1937
|
&& (!nextMessageId || String(existing.dataset.messageId || '') === nextMessageId)) {
|
|
@@ -1973,6 +1992,14 @@ export function showThinking(messageId = null, autoScroll = true) {
|
|
|
1973
1992
|
export function removeThinking() {
|
|
1974
1993
|
thinkingMessageId = null;
|
|
1975
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() {
|
|
1976
2003
|
const hadBubble = !!document.getElementById('thinking-indicator');
|
|
1977
2004
|
const wasCompacting = !!document.getElementById('thinking-indicator')?.dataset?.compactBoundary;
|
|
1978
2005
|
unobserveThinkingBubble();
|
|
@@ -1985,6 +2012,43 @@ export function removeThinking() {
|
|
|
1985
2012
|
if (hadBubble) syncAgentExchangeFoldsNow();
|
|
1986
2013
|
}
|
|
1987
2014
|
|
|
2015
|
+
// The turn ends under a reader who is not at the end: the live bubble is
|
|
2016
|
+
// taken off the page and the reply row is put in its place. The page is
|
|
2017
|
+
// shorter for a moment (scrollTop can be clamped), and the reply is not as
|
|
2018
|
+
// tall as the bubble was (its thoughts are folded). Whatever the reader looks
|
|
2019
|
+
// at stays where it is: the block of text at the top of the viewport is noted
|
|
2020
|
+
// before the swap and put back after it. Read in the bubble's stream, it is
|
|
2021
|
+
// found again in the reply; in a row above or below the bubble, in that row.
|
|
2022
|
+
export function captureLiveReplyReadingPosition() {
|
|
2023
|
+
const el = getMessagesElement();
|
|
2024
|
+
if (!el) return null;
|
|
2025
|
+
const bubble = document.getElementById('thinking-indicator');
|
|
2026
|
+
return {
|
|
2027
|
+
scrollTop: el.scrollTop,
|
|
2028
|
+
block: captureReadingBlock(el),
|
|
2029
|
+
// Below the bubble the pixel position is the wrong fallback: everything
|
|
2030
|
+
// the reader sees moves up or down with the swap, and scrollTop with it.
|
|
2031
|
+
belowBubble: !!bubble && bubble.getBoundingClientRect().bottom <= el.getBoundingClientRect().top,
|
|
2032
|
+
};
|
|
2033
|
+
}
|
|
2034
|
+
|
|
2035
|
+
export function restoreLiveReplyReadingPosition(position) {
|
|
2036
|
+
const el = getMessagesElement();
|
|
2037
|
+
if (!el || !position || isChatInteractionHeld()) return;
|
|
2038
|
+
if (restoreReadingBlock(el, position.block)) return;
|
|
2039
|
+
if (!position.belowBubble) el.scrollTop = position.scrollTop;
|
|
2040
|
+
}
|
|
2041
|
+
|
|
2042
|
+
// A rebuild of the transcript on the page that nobody asked to scroll: the
|
|
2043
|
+
// reader keeps the text they are at, or failing that the pixel position.
|
|
2044
|
+
function renderMessagesInPlace(msgs, meta) {
|
|
2045
|
+
const el = getMessagesElement();
|
|
2046
|
+
const reading = el ? { scrollTop: el.scrollTop, block: captureReadingBlock(el) } : null;
|
|
2047
|
+
const rendered = renderMessages(msgs, false, meta);
|
|
2048
|
+
if (el && reading && rendered && !restoreReadingBlock(el, reading.block)) el.scrollTop = reading.scrollTop;
|
|
2049
|
+
return rendered;
|
|
2050
|
+
}
|
|
2051
|
+
|
|
1988
2052
|
// The live turn's compaction line: the bubble stamps the turn's compaction
|
|
1989
2053
|
// for the separator pass — "Compacting context…" while the last compaction
|
|
1990
2054
|
// entry is a start without an end, the final label once the boundary is in —
|
|
@@ -2749,11 +2813,16 @@ export function applyRelayStreamEvent({
|
|
|
2749
2813
|
|
|
2750
2814
|
export function flushDeferredMessageRender() {
|
|
2751
2815
|
if (!deferredMessageRender) return;
|
|
2752
|
-
const { msgs,
|
|
2816
|
+
const { msgs, meta } = deferredMessageRender;
|
|
2753
2817
|
deferredMessageRender = null;
|
|
2754
2818
|
const deferredConversationId = String(meta?.conversationId || '').trim();
|
|
2755
2819
|
if (deferredConversationId && deferredConversationId !== String(currentConvId || '').trim()) return;
|
|
2756
|
-
|
|
2820
|
+
// Whoever asked for this render has long returned, and with it whatever
|
|
2821
|
+
// would have put the reader back; and where the reader was when it was
|
|
2822
|
+
// held back is not where they are now. Decided as it runs: at the end it
|
|
2823
|
+
// follows, anywhere else it leaves them at the text they read.
|
|
2824
|
+
if (followsTranscriptEnd()) renderMessages(msgs, true, meta);
|
|
2825
|
+
else renderMessagesInPlace(msgs, meta);
|
|
2757
2826
|
}
|
|
2758
2827
|
|
|
2759
2828
|
// Blacklists the id from further live relay_stream frames. Kept separate from
|
|
@@ -3077,6 +3146,7 @@ export function replaceRenderedMessage(msg, msgId) {
|
|
|
3077
3146
|
}
|
|
3078
3147
|
|
|
3079
3148
|
async function resendStoppedSteer(conversationId, markerId) {
|
|
3149
|
+
const send = beginOwnSend();
|
|
3080
3150
|
const conversationKey = String(conversationId || '').trim();
|
|
3081
3151
|
const id = String(markerId || '').trim();
|
|
3082
3152
|
if (!conversationKey || !id || stoppedSteerResends.has(id)) return;
|
|
@@ -3118,9 +3188,12 @@ async function resendStoppedSteer(conversationId, markerId) {
|
|
|
3118
3188
|
showTransientRelayNotice('Select a reasoning effort after refreshing model metadata.');
|
|
3119
3189
|
return;
|
|
3120
3190
|
}
|
|
3191
|
+
// As a send from the composer (loadEndForOwnSend): the Resend goes to
|
|
3192
|
+
// the end.
|
|
3193
|
+
if (isCurrentConversation(conversationKey)) await loadEndForOwnSend();
|
|
3121
3194
|
trackPendingUserMessage(clientMessageId, conversationKey, text, { attachments });
|
|
3122
3195
|
pendingUserMessageIds.add(clientMessageId);
|
|
3123
|
-
if (isCurrentConversation(conversationKey)) {
|
|
3196
|
+
if (isCurrentConversation(conversationKey) && isConversationWindowAtTail()) {
|
|
3124
3197
|
appendMessage({
|
|
3125
3198
|
role: 'user',
|
|
3126
3199
|
text,
|
|
@@ -3128,7 +3201,7 @@ async function resendStoppedSteer(conversationId, markerId) {
|
|
|
3128
3201
|
mode: selectedMode,
|
|
3129
3202
|
timestamp: new Date().toISOString(),
|
|
3130
3203
|
attachments,
|
|
3131
|
-
},
|
|
3204
|
+
}, ownSendFollows(send), clientMessageId, true);
|
|
3132
3205
|
bubbleAppended = true;
|
|
3133
3206
|
}
|
|
3134
3207
|
const r = await sendMessageApi({
|
|
@@ -3172,7 +3245,7 @@ async function resendStoppedSteer(conversationId, markerId) {
|
|
|
3172
3245
|
showTransientRelayNotice(`Resent without ${dropped} attachment${dropped === 1 ? '' : 's'} that ${dropped === 1 ? 'is' : 'are'} no longer available.`, 7000);
|
|
3173
3246
|
}
|
|
3174
3247
|
if (cliOnline && isCurrentConversation(conversationKey) && !getActiveTurnForConversation(conversationKey)?.messageId) {
|
|
3175
|
-
showThinking(r.messageId || null);
|
|
3248
|
+
showThinking(r.messageId || null, ownSendFollows(send));
|
|
3176
3249
|
}
|
|
3177
3250
|
} catch (error) {
|
|
3178
3251
|
if (!hasSendEchoArrived(clientMessageId)) {
|
|
@@ -3341,6 +3414,35 @@ export function syncRenderedPinState(pinnedIds) {
|
|
|
3341
3414
|
}
|
|
3342
3415
|
}
|
|
3343
3416
|
|
|
3417
|
+
const HIDDEN_LABEL_HTML = '<span class="msg-hidden-label">Hidden from shared viewers</span>';
|
|
3418
|
+
|
|
3419
|
+
function shareVisibilityButtonTitle(hidden) {
|
|
3420
|
+
return hidden ? 'Shows this message in shared conversations' : 'Hides this message from shared conversations';
|
|
3421
|
+
}
|
|
3422
|
+
|
|
3423
|
+
// The hidden list changed (this page, another device): bring the bubbles on
|
|
3424
|
+
// the page in line without rebuilding them. A button whose own round trip is
|
|
3425
|
+
// still out keeps its "Hiding…" until the answer.
|
|
3426
|
+
export function syncRenderedShareVisibility(hiddenIds) {
|
|
3427
|
+
const el = getMessagesElement();
|
|
3428
|
+
if (!el) return;
|
|
3429
|
+
const ids = hiddenIds instanceof Set ? hiddenIds : new Set(hiddenIds || []);
|
|
3430
|
+
for (const button of el.querySelectorAll('.msg-share-visibility-btn')) {
|
|
3431
|
+
const messageId = String(button.dataset.messageId || '').trim();
|
|
3432
|
+
if (shareVisibilityInFlight.has(messageId)) continue;
|
|
3433
|
+
const hidden = ids.has(messageId);
|
|
3434
|
+
if ((button.dataset.hiddenFromShares === 'true') === hidden) continue;
|
|
3435
|
+
button.dataset.hiddenFromShares = hidden ? 'true' : 'false';
|
|
3436
|
+
button.disabled = false;
|
|
3437
|
+
button.textContent = hidden ? 'Unhide' : 'Hide';
|
|
3438
|
+
button.title = shareVisibilityButtonTitle(hidden);
|
|
3439
|
+
const sibling = button.previousElementSibling;
|
|
3440
|
+
const label = sibling?.classList?.contains('msg-hidden-label') ? sibling : null;
|
|
3441
|
+
if (hidden && !label) button.insertAdjacentHTML('beforebegin', HIDDEN_LABEL_HTML);
|
|
3442
|
+
if (!hidden && label) label.remove();
|
|
3443
|
+
}
|
|
3444
|
+
}
|
|
3445
|
+
|
|
3344
3446
|
async function toggleMessageShareVisibility(conversationId, messageId, hiddenFromShares) {
|
|
3345
3447
|
const conversationKey = String(conversationId || '').trim();
|
|
3346
3448
|
const targetMessageId = String(messageId || '').trim();
|
|
@@ -3359,13 +3461,17 @@ async function toggleMessageShareVisibility(conversationId, messageId, hiddenFro
|
|
|
3359
3461
|
button.disabled = false;
|
|
3360
3462
|
button.textContent = hiddenFromShares ? 'Unhide' : 'Hide';
|
|
3361
3463
|
}
|
|
3362
|
-
showTransientRelayNotice('Could not update shared-message visibility.');
|
|
3464
|
+
showTransientRelayNotice(String(result?.error || '').trim() || 'Could not update shared-message visibility.');
|
|
3363
3465
|
return;
|
|
3364
3466
|
}
|
|
3365
3467
|
|
|
3366
3468
|
showTransientRelayNotice(result.hiddenFromShares
|
|
3367
3469
|
? 'Message hidden from shared viewers.'
|
|
3368
3470
|
: 'Message visible to shared viewers.');
|
|
3471
|
+
// Stored under its own conversation: the 🙈 count, even after a switch.
|
|
3472
|
+
if ('hiddenMessages' in result) {
|
|
3473
|
+
setConversationHiddenMessages(conversationKey, result.hiddenMessages, result.hiddenMessagesRevision);
|
|
3474
|
+
}
|
|
3369
3475
|
// The reload below only refreshes the open view; if the user switched
|
|
3370
3476
|
// conversations while the toggle round trip was in flight, rendering it
|
|
3371
3477
|
// would replace the new conversation's messages with this one's.
|
|
@@ -3375,7 +3481,7 @@ async function toggleMessageShareVisibility(conversationId, messageId, hiddenFro
|
|
|
3375
3481
|
});
|
|
3376
3482
|
if (String(currentConvId || '').trim() !== conversationKey) return;
|
|
3377
3483
|
if (response?.messages) {
|
|
3378
|
-
|
|
3484
|
+
renderMessagesInPlace(response.messages, response);
|
|
3379
3485
|
}
|
|
3380
3486
|
}
|
|
3381
3487
|
|
|
@@ -3545,8 +3651,8 @@ export function appendMessage(msg, scroll = true, msgId = null, force = false, i
|
|
|
3545
3651
|
const node = createMessageNode(msg, msgId, force);
|
|
3546
3652
|
const isNewNode = !!node && !node.parentNode;
|
|
3547
3653
|
// A reply the socket delivers without its prompt's id in the message (a
|
|
3548
|
-
// failed turn's note) is placed under that prompt, and
|
|
3549
|
-
//
|
|
3654
|
+
// failed turn's note) is placed under that prompt, and an agent prompt's
|
|
3655
|
+
// row reads its status from it.
|
|
3550
3656
|
const exchangeRow = isNewNode ? node[AGENT_EXCHANGE_ROW_KEY] : null;
|
|
3551
3657
|
const anchorId = String(insertAfterId || '').trim();
|
|
3552
3658
|
if (exchangeRow?.role === 'assistant' && !exchangeRow.sourceMessageId && anchorId && anchorId !== String(msgId || '').trim()) {
|
|
@@ -3771,6 +3877,8 @@ export function renderMessages(msgs, scroll = true, meta = {}) {
|
|
|
3771
3877
|
return false;
|
|
3772
3878
|
}
|
|
3773
3879
|
deferredMessageRender = null;
|
|
3880
|
+
// Other rows: whoever renders them puts the reader in place.
|
|
3881
|
+
releaseReadingAnchorHold();
|
|
3774
3882
|
const messageById = new Map(
|
|
3775
3883
|
ordered
|
|
3776
3884
|
.map((item) => [String(item?.id || '').trim(), item])
|
|
@@ -3828,7 +3936,9 @@ export function renderMessages(msgs, scroll = true, meta = {}) {
|
|
|
3828
3936
|
// in as an extra row, and another conversation's bubble must not leak into
|
|
3829
3937
|
// this one (it is dropped instead, so the next restore builds afresh).
|
|
3830
3938
|
const thinkingNodeInTranscript = !!thinkingNode && thinkingNode.parentNode === el;
|
|
3939
|
+
// Nor under a window that ends above the conversation's end (showThinking).
|
|
3831
3940
|
const preservedThinkingNode = (thinkingNodeInTranscript
|
|
3941
|
+
&& !hasMoreNewer
|
|
3832
3942
|
&& thinkingNode.dataset.conversationId === renderConversationKey) ? thinkingNode : null;
|
|
3833
3943
|
if (thinkingNodeInTranscript && !preservedThinkingNode) removeThinking();
|
|
3834
3944
|
if (!ordered.length && !preservedPendingNodes.length && !preservedThinkingNode) {
|
|
@@ -3942,12 +4052,348 @@ export function isConversationWindowAtTail() {
|
|
|
3942
4052
|
return !conversationHistoryState.hasMoreNewer;
|
|
3943
4053
|
}
|
|
3944
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
|
+
|
|
3945
4391
|
export function focusConversationMessageById(messageId, { behavior = 'smooth', block = 'center' } = {}) {
|
|
3946
4392
|
const id = String(messageId || '').trim();
|
|
3947
4393
|
if (!id) return false;
|
|
3948
4394
|
const el = getMessagesElement();
|
|
3949
4395
|
if (!el) return false;
|
|
3950
|
-
// A
|
|
4396
|
+
// A folded agent prompt is opened before it is scrolled to.
|
|
3951
4397
|
revealAgentExchangeMessage(id);
|
|
3952
4398
|
const target = el.querySelector(`.msg[data-message-id="${CSS.escape(id)}"]`);
|
|
3953
4399
|
if (!target) return false;
|
|
@@ -3989,7 +4435,23 @@ export function compactCurrentConversation() {
|
|
|
3989
4435
|
// edit re-arms the warning.
|
|
3990
4436
|
let unknownCommandWarned = null;
|
|
3991
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
|
+
|
|
3992
4451
|
export async function sendMessage() {
|
|
4452
|
+
// The reader's own send, as of now: what follows it scrolls to the end
|
|
4453
|
+
// only while they have not scrolled since.
|
|
4454
|
+
const send = beginOwnSend();
|
|
3993
4455
|
const input = document.getElementById('msg-input');
|
|
3994
4456
|
const originalComposerText = String(input?.value || '');
|
|
3995
4457
|
const text = originalComposerText.trim();
|
|
@@ -4030,7 +4492,7 @@ export async function sendMessage() {
|
|
|
4030
4492
|
releaseComposerFocusAfterSend(input);
|
|
4031
4493
|
void flushConversationDraft(currentConvId);
|
|
4032
4494
|
compactCurrentConversation();
|
|
4033
|
-
|
|
4495
|
+
scrollAfterOwnSend(send);
|
|
4034
4496
|
return;
|
|
4035
4497
|
}
|
|
4036
4498
|
|
|
@@ -4087,7 +4549,10 @@ export async function sendMessage() {
|
|
|
4087
4549
|
let attachments = [];
|
|
4088
4550
|
let clientMessageId = null;
|
|
4089
4551
|
try {
|
|
4552
|
+
// Alongside the upload: the end is loaded while the files go up.
|
|
4553
|
+
const endLoaded = viewingSendConversation() ? loadEndForOwnSend() : null;
|
|
4090
4554
|
attachments = await uploadAttachments(draftAttachments);
|
|
4555
|
+
await endLoaded;
|
|
4091
4556
|
|
|
4092
4557
|
const isNew = !targetConversationId;
|
|
4093
4558
|
const msgTimestamp = new Date().toISOString();
|
|
@@ -4107,8 +4572,14 @@ export async function sendMessage() {
|
|
|
4107
4572
|
input.value = '';
|
|
4108
4573
|
autoResize(input);
|
|
4109
4574
|
releaseComposerFocusAfterSend(input);
|
|
4110
|
-
|
|
4111
|
-
|
|
4575
|
+
// Asked before the bubble is on the page.
|
|
4576
|
+
const follow = ownSendFollows(send);
|
|
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
|
+
}
|
|
4582
|
+
scrollAfterOwnSend(send);
|
|
4112
4583
|
}
|
|
4113
4584
|
|
|
4114
4585
|
const body = {
|
|
@@ -4196,7 +4667,8 @@ export async function sendMessage() {
|
|
|
4196
4667
|
await window.openConversation?.(r.compactedConversationId);
|
|
4197
4668
|
removeComposerAttachments(draftAttachments);
|
|
4198
4669
|
if (!mobileSend) input.focus();
|
|
4199
|
-
|
|
4670
|
+
// The send goes on in the conversation the compaction opened.
|
|
4671
|
+
scrollAfterOwnSend({ ...send, conversationId: String(r.compactedConversationId || '').trim() });
|
|
4200
4672
|
}
|
|
4201
4673
|
return;
|
|
4202
4674
|
}
|
|
@@ -4217,6 +4689,7 @@ export async function sendMessage() {
|
|
|
4217
4689
|
if (adoptNewConversation) {
|
|
4218
4690
|
setCurrentConv(r.conversationId);
|
|
4219
4691
|
composerConversationId = r.conversationId;
|
|
4692
|
+
nameOwnSendConversation(send, r.conversationId);
|
|
4220
4693
|
}
|
|
4221
4694
|
conversations[r.conversationId] = {
|
|
4222
4695
|
id: r.conversationId,
|
|
@@ -4263,7 +4736,7 @@ export async function sendMessage() {
|
|
|
4263
4736
|
// pinned to the newest message. Re-pin now rather than after the draft
|
|
4264
4737
|
// save's round trip below, or the new bubble sits under the composer
|
|
4265
4738
|
// for as long as that request takes.
|
|
4266
|
-
if (viewingSendConversation()) scrollBottom();
|
|
4739
|
+
if (viewingSendConversation() && ownSendFollows(send)) scrollBottom();
|
|
4267
4740
|
const persistedConversationId = String(r.conversationId || targetConversationId || '').trim();
|
|
4268
4741
|
if (persistedConversationId) {
|
|
4269
4742
|
restorableDraftByConversation.delete(persistedConversationId);
|
|
@@ -4295,7 +4768,7 @@ export async function sendMessage() {
|
|
|
4295
4768
|
// A turn already running here owns the live bubble; adopting the
|
|
4296
4769
|
// just-queued id would re-anchor it under the newest user message.
|
|
4297
4770
|
const activeTurn = getActiveTurnForConversation(composerConversationId);
|
|
4298
|
-
if (!activeTurn?.messageId) showThinking(r.messageId || null);
|
|
4771
|
+
if (!activeTurn?.messageId) showThinking(r.messageId || null, ownSendFollows(send));
|
|
4299
4772
|
}
|
|
4300
4773
|
|
|
4301
4774
|
// Only what this message took leaves the composer: attachments added (or
|
|
@@ -4304,7 +4777,7 @@ export async function sendMessage() {
|
|
|
4304
4777
|
if (viewingSendConversation()) {
|
|
4305
4778
|
removeComposerAttachments(draftAttachments);
|
|
4306
4779
|
if (!mobileSend) input.focus();
|
|
4307
|
-
|
|
4780
|
+
scrollAfterOwnSend(send);
|
|
4308
4781
|
}
|
|
4309
4782
|
} catch (e) {
|
|
4310
4783
|
const sendLanded = hasSendEchoArrived(clientMessageId);
|