@oar-sh/oar 0.9.9 → 0.9.11
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 +561 -1
- package/README.md +162 -909
- package/bin/bat/oar.bat +46 -46
- package/package.json +14 -14
- package/server/README.md +369 -13
- package/server/claude-worker/claude-remote-relay-tool.mjs +10 -0
- package/server/claude-worker/claude-session-process.mjs +121 -9
- package/server/claude-worker/claude-session-worker.mjs +9 -1
- package/server/claude-worker/claude-turn-publisher.mjs +3 -0
- package/server/claude-worker/sdk-message-normalizer.mjs +31 -4
- package/server/claude-worker/subagent-timeline-recorder.mjs +572 -0
- package/server/copilot-worker/copilot-sdk-session-process.mjs +79 -12
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +6 -0
- package/server/cursor-worker/cursor-sdk-adapter.mjs +56 -2
- package/server/db-schema.mjs +95 -2
- package/server/grok-worker/acp-client.mjs +2 -1
- package/server/grok-worker/acp-host-services.mjs +4 -3
- package/server/migrations/0009-subagent-timeline.mjs +135 -0
- package/server/migrations/0010-subagent-timeline-pruning.mjs +89 -0
- package/server/migrations/0011-relay-event-subscriptions.mjs +125 -0
- package/server/migrations/0012-cross-relay-event-subscriptions.mjs +116 -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/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 +187 -0
- package/server/public/app/api-client.js +312 -16
- package/server/public/app/archive-confirm.mjs +28 -0
- package/server/public/app/ask-user-view.js +17 -12
- package/server/public/app/attachments-view.js +516 -6
- package/server/public/app/background-tasks-view.mjs +18 -26
- package/server/public/app/bootstrap.js +336 -59
- package/server/public/app/chat-actions-menu-state.mjs +126 -0
- package/server/public/app/claude-cloud-conversation-ui.js +10 -22
- package/server/public/app/composer-control-state.mjs +16 -0
- package/server/public/app/composer-keyboard.mjs +54 -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-turn-state.mjs +81 -0
- package/server/public/app/conversation-view.js +436 -117
- package/server/public/app/cwd-picker.js +1 -1
- package/server/public/app/explorer-edit-state.mjs +68 -0
- package/server/public/app/hidden-messages-view.mjs +312 -0
- package/server/public/app/host-suspend-ui.js +13 -4
- package/server/public/app/journal-view.js +930 -45
- package/server/public/app/no-conversation-state.mjs +58 -0
- package/server/public/app/preview-cards.mjs +117 -31
- package/server/public/app/preview-command.mjs +10 -1
- package/server/public/app/provider-readiness.mjs +120 -0
- package/server/public/app/relay-board-view.js +19 -3
- package/server/public/app/relay-event-subscriptions-ui.js +184 -0
- package/server/public/app/relay-event-view.mjs +234 -0
- package/server/public/app/relay-restart-error.mjs +16 -0
- package/server/public/app/settings-modal.js +93 -9
- 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 +137 -40
- package/server/public/app/store.js +115 -32
- package/server/public/app/subagent-row.mjs +138 -0
- package/server/public/app/subagent-timeline-view.mjs +186 -0
- package/server/public/app/subagent-viewer-watch.mjs +50 -0
- package/server/public/app/subagent-viewer.mjs +558 -0
- package/server/public/app/task-row-format.mjs +29 -0
- package/server/public/app/transcript-jump-bottom-view.mjs +464 -0
- package/server/public/app/transcript-jump-bottom.mjs +210 -0
- package/server/public/app/transcript-reload-freshness.mjs +25 -0
- package/server/public/app/transcript-viewport-anchor.mjs +140 -0
- package/server/public/app/update-ui.js +1 -1
- package/server/public/app/viewer-history.mjs +5 -3
- package/server/public/index.html +774 -12
- package/server/relay-tools.md +2 -1
- package/server/relay.mjs +2 -1
- package/server/repositories/conversation-group-repository.mjs +444 -0
- package/server/repositories/message-repository.mjs +30 -3
- package/server/repositories/question-repository.mjs +9 -3
- package/server/repositories/remote-relay-repository.mjs +6 -2
- 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/repositories/subagent-timeline-statements.mjs +170 -0
- package/server/routes/about-routes.mjs +15 -0
- package/server/routes/ask-user-routes.mjs +3 -0
- package/server/routes/cache-routes.mjs +4 -0
- package/server/routes/conversation-group-routes.mjs +171 -0
- package/server/routes/messages-routes.mjs +698 -114
- package/server/routes/preview-routes.mjs +9 -1
- package/server/routes/relay-event-routes.mjs +157 -0
- package/server/routes/remote-relay-routes.mjs +15 -0
- package/server/routes/sessions-routes.mjs +843 -193
- package/server/server-runtime.mjs +559 -160
- package/server/services/about-service.mjs +461 -0
- package/server/services/background-task-store.mjs +128 -0
- package/server/services/background-work-view.mjs +228 -0
- package/server/services/cache-rebuild-service.mjs +30 -0
- package/server/services/delete-archive-service.mjs +4 -1
- package/server/services/detached-worker-watch-service.mjs +150 -0
- package/server/services/explorer-entry-name.mjs +26 -0
- package/server/services/explorer-mutations.mjs +122 -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 +49 -27
- 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/push-dispatch-service.mjs +18 -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-link-service.mjs +289 -0
- package/server/services/relay-event-service.mjs +1952 -0
- package/server/services/relay-shutdown-gate.mjs +31 -0
- package/server/services/remote-relay-caller-context.mjs +52 -7
- package/server/services/remote-relay-dispatcher.mjs +551 -7
- package/server/services/remote-relay-registry-service.mjs +11 -1
- package/server/services/request-boundary.mjs +183 -0
- package/server/services/session-activity-tracker.mjs +695 -0
- package/server/services/session-history-refresh-service.mjs +7 -0
- 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/stale-attempt-log-limiter.mjs +96 -0
- package/server/services/stale-recovery-clock-guard.mjs +57 -0
- package/server/services/stale-subagent-run-sweep.mjs +109 -0
- package/server/services/status-event-service.mjs +7 -3
- package/server/services/subagent-record-retention.mjs +164 -0
- package/server/services/subagent-timeline-service.mjs +328 -0
- package/server/services/subagent-viewer-rooms.mjs +119 -0
- package/server/services/tunnel-worker-path-guard.mjs +24 -41
- package/server/services/upgrade-socket-guard.mjs +16 -0
- package/server/services/windows-autostart-service.mjs +6 -1
- package/server/services/windows-boot-autostart-service.mjs +207 -23
- package/shared/preview-tool-core.mjs +15 -5
- package/shared/relay-events.mjs +371 -0
- package/shared/remote-relay-contract.mjs +160 -6
- package/shared/share-path-privacy.mjs +30 -0
- package/shared/subagent-record-retention.mjs +31 -0
- package/shared/subagent-timeline.mjs +79 -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/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,6 +46,7 @@ import {
|
|
|
47
46
|
getSessionWorkerState,
|
|
48
47
|
openSummaryModal,
|
|
49
48
|
closeSummaryModal,
|
|
49
|
+
toggleSidebar,
|
|
50
50
|
} from './store.js';
|
|
51
51
|
import { getPendingQuestionCountsByConversation } from './ask-user-view.js';
|
|
52
52
|
import { sendMessage as sendMessageApi, cancelConversationTurn, cancelQueuedConversationTurn, cancelSubagentRun, compactConversation as compactConversationApi, scheduleContextUsageRefresh, loadConversation as loadConversationApi, loadSharedConversation, updateConversationDraft as updateConversationDraftApi, updateMessageShareVisibility, launchSessionWorker } from './api-client.js';
|
|
@@ -55,12 +55,25 @@ import { linkifyWorkspaceMentionsInNode, parseAppFileHref, renderMarkdownPreview
|
|
|
55
55
|
import { renderAttachmentMarkup, removeComposerAttachments, uploadAttachments, setComposerAttachments, setRepoBrowserSessionInfo, openGalleryItem } from './attachments-view.js';
|
|
56
56
|
import { chatMediaGallery, chatMediaItemFromNode } from './viewer-chat-gallery.mjs';
|
|
57
57
|
import { buildWorkflowRunCard } from './background-tasks-view.mjs';
|
|
58
|
+
import { mergeLiveSubagentRun, subagentRowsMarkup } from './subagent-row.mjs';
|
|
59
|
+
import { rememberSubagentRuns, syncSubagentRowClock } from './subagent-viewer.mjs';
|
|
58
60
|
import { isMessagePinned, toggleMessagePin } from './pinned-messages-view.mjs';
|
|
61
|
+
import { isMessageHiddenFromShares, setConversationHiddenMessages } from './hidden-messages-view.mjs';
|
|
59
62
|
import { parsePreviewCommand, runPreviewCommand } from './preview-command.mjs';
|
|
60
63
|
import { buildTranscriptPreviewCard } from './preview-cards.mjs';
|
|
61
64
|
import { closeSlashAutocomplete, handleSlashAutocompleteKey, updateSlashAutocomplete } from './slash-autocomplete.mjs';
|
|
62
65
|
import { closeMentionAutocomplete, handleMentionAutocompleteKey, updateMentionAutocomplete } from './mention-autocomplete.mjs';
|
|
63
66
|
import { renderRemoteOriginBadgeHtml } from './remote-relay-origin-view.mjs';
|
|
67
|
+
import { isRelayEventMessage, renderRelayEventRowHtml } from './relay-event-view.mjs';
|
|
68
|
+
import {
|
|
69
|
+
AGENT_EXCHANGE_CLOSED_CLASS,
|
|
70
|
+
AGENT_EXCHANGE_HEAD_CLASS,
|
|
71
|
+
agentExchangeFoldOpen,
|
|
72
|
+
agentExchangeRowOf,
|
|
73
|
+
agentExchangeSummary,
|
|
74
|
+
planAgentExchangeFolds,
|
|
75
|
+
renderAgentExchangeRowHtml,
|
|
76
|
+
} from './agent-exchange-fold-view.mjs';
|
|
64
77
|
import { stripRemoteRelayDisplayArtifacts } from './relay-prompt-sanitizer.mjs';
|
|
65
78
|
import { evaluateUnknownCommandGuard } from './slash-commands.mjs';
|
|
66
79
|
import { attachCodeCopyButtons } from './code-copy.mjs';
|
|
@@ -98,9 +111,11 @@ import {
|
|
|
98
111
|
syncSeparatorRail,
|
|
99
112
|
syncTranscriptSeparators,
|
|
100
113
|
} from './transcript-separators.mjs';
|
|
101
|
-
import { ABSORBED_MSG_CLASS, STEER_MARKER_CLASS_BY_KIND, syncSteeredTurnMerge } from './steered-turn-merge.mjs';
|
|
114
|
+
import { ABSORBED_MSG_CLASS, STEERED_MSG_CLASS, STEER_MARKER_CLASS_BY_KIND, syncSteeredTurnMerge } from './steered-turn-merge.mjs';
|
|
102
115
|
import { resolveSubagentFold, subagentFoldSummary, toggledSubagentFoldChoice } from './subagent-fold.mjs';
|
|
103
116
|
import { deriveComposerControlState, hasComposerDraft, hasUploadingAttachments } from './composer-control-state.mjs';
|
|
117
|
+
import { resolveNoConversationState } from './no-conversation-state.mjs';
|
|
118
|
+
import { getProviderReadiness, isProviderSetupRequired } from './provider-readiness.mjs';
|
|
104
119
|
import { buildLiveMessageFingerprint } from './live-message-dedupe.mjs';
|
|
105
120
|
import { createInfiniteLoader } from './infinite-loader.js';
|
|
106
121
|
import { normalizeDraftTimestampMs, isIncomingDraftTimestampStale } from './conversation-draft-timestamp-utils.mjs';
|
|
@@ -116,6 +131,7 @@ import {
|
|
|
116
131
|
shouldApplyIncomingDraftToComposer,
|
|
117
132
|
} from './conversation-draft-sync.mjs';
|
|
118
133
|
import { isChatInteractionHeld, selectionIntersectsNode } from './selection-guard.mjs';
|
|
134
|
+
import { captureReadingBlock, restoreReadingBlock } from './transcript-viewport-anchor.mjs';
|
|
119
135
|
import {
|
|
120
136
|
applyThinkingFollow,
|
|
121
137
|
getThinkingFollowMode,
|
|
@@ -126,6 +142,7 @@ import {
|
|
|
126
142
|
toggleThinkingFollowMode,
|
|
127
143
|
unobserveThinkingBubble,
|
|
128
144
|
} from './thinking-follow.js';
|
|
145
|
+
import { beginOwnSend, followsTranscriptEnd, nameOwnSendConversation, ownSendFollows, scrollAfterOwnSend } from './transcript-jump-bottom-view.mjs';
|
|
129
146
|
import { buildInFlightSnapshotKey } from './in-flight-snapshot.mjs';
|
|
130
147
|
import { computeStablePrefixLength, planListPatch } from './streaming-dom-patch.mjs';
|
|
131
148
|
import { prefixToolActivityEmoji } from './tool-activity-emoji.mjs';
|
|
@@ -150,6 +167,8 @@ function syncSeparatorsNow() {
|
|
|
150
167
|
if (!el) return;
|
|
151
168
|
syncTranscriptSeparators(el);
|
|
152
169
|
syncSteeredTurnMerge(el);
|
|
170
|
+
// After the merge pass: a steered row's class is one of its inputs.
|
|
171
|
+
syncAgentExchangeFolds(el);
|
|
153
172
|
observeTranscriptResize(el);
|
|
154
173
|
}
|
|
155
174
|
|
|
@@ -265,6 +284,7 @@ export function jumpToImageParent(messageId) {
|
|
|
265
284
|
showTransientRelayNotice('Load earlier messages to view the source image.');
|
|
266
285
|
return;
|
|
267
286
|
}
|
|
287
|
+
revealAgentExchangeMessage(id);
|
|
268
288
|
node.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
269
289
|
node.classList.add('message-highlight');
|
|
270
290
|
window.setTimeout(() => node.classList.remove('message-highlight'), 1400);
|
|
@@ -355,6 +375,8 @@ const conversationHistoryLoader = createInfiniteLoader({
|
|
|
355
375
|
// A steering pair split across the page boundary heals here (the absorbed
|
|
356
376
|
// reply just arrived above its already-rendered steered user row).
|
|
357
377
|
syncSteeredTurnMerge(el);
|
|
378
|
+
// The page's agent exchanges fold before the height delta is measured.
|
|
379
|
+
syncAgentExchangeFolds(el);
|
|
358
380
|
requestAnimationFrame(() => {
|
|
359
381
|
if (!el || String(currentConvId || '').trim() !== currentId) return;
|
|
360
382
|
const nextScrollHeight = el.scrollHeight;
|
|
@@ -555,11 +577,28 @@ function syncSendButtonState() {
|
|
|
555
577
|
steeringHeld: steeringHold.held,
|
|
556
578
|
steeringHoldReason: steeringHold.reason,
|
|
557
579
|
archived: conversations[String(currentConvId || '').trim()]?.archived === true,
|
|
580
|
+
setupRequired: !String(currentConvId || '').trim() && isProviderSetupRequired(),
|
|
558
581
|
});
|
|
559
582
|
btn.disabled = state.disabled;
|
|
560
583
|
btn.dataset.action = state.action;
|
|
561
584
|
btn.textContent = state.label;
|
|
562
585
|
btn.title = state.title;
|
|
586
|
+
// The textarea follows: locked with the setup placeholder while no provider
|
|
587
|
+
// is ready, given back the moment readiness flips or a chat opens. The
|
|
588
|
+
// shared view owns its own read-only field and is left alone.
|
|
589
|
+
const input = document.getElementById('msg-input');
|
|
590
|
+
if (input && !IS_SHARED_VIEW) {
|
|
591
|
+
const lock = state.composerDisabled === true;
|
|
592
|
+
if (lock) {
|
|
593
|
+
input.disabled = true;
|
|
594
|
+
input.placeholder = state.placeholder;
|
|
595
|
+
input.dataset.setupLocked = '1';
|
|
596
|
+
} else if (input.dataset.setupLocked === '1') {
|
|
597
|
+
delete input.dataset.setupLocked;
|
|
598
|
+
input.disabled = false;
|
|
599
|
+
window.syncComposerPlaceholder?.();
|
|
600
|
+
}
|
|
601
|
+
}
|
|
563
602
|
}
|
|
564
603
|
|
|
565
604
|
// Button-only refresh for status, poll and question paths. It must never save
|
|
@@ -1226,6 +1265,147 @@ function splitVariantId(modelVariantId = '') {
|
|
|
1226
1265
|
};
|
|
1227
1266
|
}
|
|
1228
1267
|
|
|
1268
|
+
// The user's fold of an event row, by message id: a re-render of the
|
|
1269
|
+
// transcript must not open a row the user folded (or fold one they opened).
|
|
1270
|
+
const relayEventFoldChoices = new Map();
|
|
1271
|
+
|
|
1272
|
+
function createRelayEventNode(msg, msgId) {
|
|
1273
|
+
const div = document.createElement('div');
|
|
1274
|
+
div.className = 'msg relay-event';
|
|
1275
|
+
if (msgId) div.dataset.messageId = msgId;
|
|
1276
|
+
const fingerprint = buildLiveMessageFingerprint({ ...msg, id: msgId || msg?.id || '' });
|
|
1277
|
+
div.dataset.messageRole = fingerprint.role || '';
|
|
1278
|
+
div.dataset.messageTextFingerprint = fingerprint.text || '';
|
|
1279
|
+
div.dataset.messageTimestamp = String(msg?.timestamp || '').trim();
|
|
1280
|
+
div.innerHTML = renderRelayEventRowHtml(msg, { escHtml, timeText: msg?.timestamp ? fmtDate(msg.timestamp) : '' });
|
|
1281
|
+
const row = div.querySelector('details');
|
|
1282
|
+
if (row && msgId) {
|
|
1283
|
+
if (relayEventFoldChoices.has(msgId)) row.open = relayEventFoldChoices.get(msgId);
|
|
1284
|
+
row.addEventListener('toggle', () => relayEventFoldChoices.set(msgId, row.open));
|
|
1285
|
+
}
|
|
1286
|
+
setAgentExchangeRow(div, msg, msgId);
|
|
1287
|
+
return div;
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
// Agent exchanges (agent-exchange-fold-view.mjs): an agent's prompt folds
|
|
1291
|
+
// into one row; this session's reply to it always shows. What the fold pass
|
|
1292
|
+
// needs of a message is kept on its node when the row is built, so the pass
|
|
1293
|
+
// reads the transcript itself and not the payload it came from.
|
|
1294
|
+
const AGENT_EXCHANGE_ROW_KEY = '__agentExchangeRow';
|
|
1295
|
+
// The user's toggle of a folded prompt, by its id: true opened, false
|
|
1296
|
+
// closed, unset none (agentExchangeFoldOpen: open while it works, else
|
|
1297
|
+
// folded). Kept through any re-render.
|
|
1298
|
+
const agentExchangeFoldChoices = new Map();
|
|
1299
|
+
// Each folded prompt's status, by its id, as of the last pass.
|
|
1300
|
+
let agentExchangeStatusByPromptId = new Map();
|
|
1301
|
+
|
|
1302
|
+
function setAgentExchangeRow(div, msg, msgId) {
|
|
1303
|
+
div[AGENT_EXCHANGE_ROW_KEY] = agentExchangeRowOf(msg, { id: msgId || msg?.id || '' });
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
// The rows the plan reads, in order, and the node of each. The live bubble
|
|
1307
|
+
// is the running turn's row; cards and boards without a reply are not rows.
|
|
1308
|
+
function agentExchangeTranscriptRows(el) {
|
|
1309
|
+
const rows = [];
|
|
1310
|
+
const nodes = new Map();
|
|
1311
|
+
for (const node of el.children) {
|
|
1312
|
+
if (!node.classList?.contains('msg')) continue;
|
|
1313
|
+
let row = null;
|
|
1314
|
+
if (node.id === 'thinking-indicator') {
|
|
1315
|
+
const id = String(node.dataset.messageId || '').trim();
|
|
1316
|
+
if (id) row = { id, role: 'assistant', live: true };
|
|
1317
|
+
} else if (node[AGENT_EXCHANGE_ROW_KEY]) {
|
|
1318
|
+
row = { ...node[AGENT_EXCHANGE_ROW_KEY], steered: node.classList.contains(STEERED_MSG_CLASS) };
|
|
1319
|
+
}
|
|
1320
|
+
if (!row) continue;
|
|
1321
|
+
rows.push(row);
|
|
1322
|
+
nodes.set(row, node);
|
|
1323
|
+
}
|
|
1324
|
+
return { rows, nodes };
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
/**
|
|
1328
|
+
* Fold the transcript's agent prompts. One idempotent pass, run after every
|
|
1329
|
+
* render and update of the list like the separator and merge passes. The
|
|
1330
|
+
* folded row lives in the prompt's own node, as its first child, so no
|
|
1331
|
+
* insertion path has to place it; closed, a class hides the prompt's bubble.
|
|
1332
|
+
* The replies and the live bubble are read for the row's status and are
|
|
1333
|
+
* otherwise left alone.
|
|
1334
|
+
*/
|
|
1335
|
+
function syncAgentExchangeFolds(el = getMessagesElement()) {
|
|
1336
|
+
if (!el) return;
|
|
1337
|
+
const { rows, nodes } = agentExchangeTranscriptRows(el);
|
|
1338
|
+
const plan = planAgentExchangeFolds(rows, {
|
|
1339
|
+
runningIds: [getActiveTurnForConversation(currentConvId)?.messageId],
|
|
1340
|
+
});
|
|
1341
|
+
const states = new Map();
|
|
1342
|
+
const statusByPromptId = new Map();
|
|
1343
|
+
for (const group of plan) {
|
|
1344
|
+
if (!group.fold) continue;
|
|
1345
|
+
const open = agentExchangeFoldOpen(group.status, agentExchangeFoldChoices.get(group.promptId));
|
|
1346
|
+
states.set(nodes.get(group.prompt), { group, open });
|
|
1347
|
+
statusByPromptId.set(group.promptId, group.status);
|
|
1348
|
+
}
|
|
1349
|
+
agentExchangeStatusByPromptId = statusByPromptId;
|
|
1350
|
+
for (const node of el.children) {
|
|
1351
|
+
if (node.classList?.contains('msg')) applyAgentExchangeState(node, states.get(node) || null);
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
// For the paths that change the list outside a render: the live bubble
|
|
1356
|
+
// coming and going. A bulk render runs the pass once at its end.
|
|
1357
|
+
export function syncAgentExchangeFoldsNow() {
|
|
1358
|
+
if (separatorSyncSuspended > 0) return;
|
|
1359
|
+
syncAgentExchangeFolds();
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
function applyAgentExchangeState(node, state) {
|
|
1363
|
+
node.classList.toggle(AGENT_EXCHANGE_HEAD_CLASS, !!state);
|
|
1364
|
+
node.classList.toggle(AGENT_EXCHANGE_CLOSED_CLASS, !!state && !state.open);
|
|
1365
|
+
const existing = node.querySelector(':scope > .agent-exchange-row');
|
|
1366
|
+
if (!state) {
|
|
1367
|
+
existing?.remove();
|
|
1368
|
+
return;
|
|
1369
|
+
}
|
|
1370
|
+
const { group, open } = state;
|
|
1371
|
+
const summary = agentExchangeSummary(group.prompt.origin, group.status, { conversations });
|
|
1372
|
+
const timeText = fmtDate(node.dataset.messageTimestamp);
|
|
1373
|
+
const key = JSON.stringify([summary.text, timeText]);
|
|
1374
|
+
let row = existing;
|
|
1375
|
+
if (!row || row.dataset.summaryKey !== key) {
|
|
1376
|
+
const holder = document.createElement('div');
|
|
1377
|
+
holder.innerHTML = renderAgentExchangeRowHtml(summary, { escHtml, timeText, open });
|
|
1378
|
+
const fresh = holder.firstElementChild;
|
|
1379
|
+
fresh.dataset.summaryKey = key;
|
|
1380
|
+
fresh.dataset.promptId = group.promptId;
|
|
1381
|
+
fresh.addEventListener('toggle', () => onAgentExchangeToggle(fresh));
|
|
1382
|
+
if (row) row.replaceWith(fresh);
|
|
1383
|
+
else node.insertBefore(fresh, node.firstChild);
|
|
1384
|
+
row = fresh;
|
|
1385
|
+
}
|
|
1386
|
+
if (row.open !== open) row.open = open;
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
function onAgentExchangeToggle(row) {
|
|
1390
|
+
const promptId = String(row.dataset.promptId || '').trim();
|
|
1391
|
+
// The pass setting `open` fires this too, and a row it has since replaced
|
|
1392
|
+
// (its status changed) fires it late; only the user's toggle is news.
|
|
1393
|
+
if (!promptId || !row.isConnected) return;
|
|
1394
|
+
const shown = agentExchangeFoldOpen(agentExchangeStatusByPromptId.get(promptId), agentExchangeFoldChoices.get(promptId));
|
|
1395
|
+
if (shown === row.open) return;
|
|
1396
|
+
agentExchangeFoldChoices.set(promptId, row.open);
|
|
1397
|
+
syncAgentExchangeFolds();
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
// A search result, a pin or a jump to a folded prompt opens it first, so
|
|
1401
|
+
// the message jumped to is the one on screen.
|
|
1402
|
+
function revealAgentExchangeMessage(messageId) {
|
|
1403
|
+
const promptId = String(messageId || '').trim();
|
|
1404
|
+
if (!agentExchangeStatusByPromptId.has(promptId) || agentExchangeFoldChoices.get(promptId) === true) return;
|
|
1405
|
+
agentExchangeFoldChoices.set(promptId, true);
|
|
1406
|
+
syncAgentExchangeFolds();
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1229
1409
|
function createMessageNode(msg, msgId = null, force = false) {
|
|
1230
1410
|
const el = getMessagesElement();
|
|
1231
1411
|
if (!el) return null;
|
|
@@ -1237,6 +1417,10 @@ function createMessageNode(msg, msgId = null, force = false) {
|
|
|
1237
1417
|
seenMessageIds.add(msgId);
|
|
1238
1418
|
}
|
|
1239
1419
|
|
|
1420
|
+
// An event message the relay wrote into a watcher's conversation: a
|
|
1421
|
+
// compact, foldable row, never a bubble of the user's.
|
|
1422
|
+
if (isRelayEventMessage(msg)) return createRelayEventNode(msg, msgId);
|
|
1423
|
+
|
|
1240
1424
|
// A prompt another relay's agent sent carries `origin`: its header line is
|
|
1241
1425
|
// hidden (the badge below says the same), and a stray remote-relay mention
|
|
1242
1426
|
// hint never shows on any user bubble.
|
|
@@ -1258,6 +1442,7 @@ function createMessageNode(msg, msgId = null, force = false) {
|
|
|
1258
1442
|
div.dataset.messageTextFingerprint = fingerprint.text || '';
|
|
1259
1443
|
div.dataset.messageTimestamp = String(msg?.timestamp || '').trim();
|
|
1260
1444
|
if (fingerprint.sourceMessageId) div.dataset.sourceMessageId = fingerprint.sourceMessageId;
|
|
1445
|
+
setAgentExchangeRow(div, msg, msgId);
|
|
1261
1446
|
|
|
1262
1447
|
const label = msg.role === 'user' ? 'You' : '';
|
|
1263
1448
|
const { baseModelId, reasoningEffort } = splitVariantId(msg.model);
|
|
@@ -1305,10 +1490,11 @@ function createMessageNode(msg, msgId = null, force = false) {
|
|
|
1305
1490
|
// (e.g. the Copilot relay answering a Cursor conversation) must be visible,
|
|
1306
1491
|
// not silent: it ran on another plan than the header indicates.
|
|
1307
1492
|
const executedProvider = String(msg?.executedProvider || '').trim().toLowerCase();
|
|
1308
|
-
const boundProvider = String(conversations[currentConvId]?.runtimeProviderType || '
|
|
1493
|
+
const boundProvider = String(conversations[currentConvId]?.runtimeProviderType || '').trim().toLowerCase();
|
|
1309
1494
|
// 'unknown' means the responder identity did not resolve, not that another
|
|
1310
|
-
// provider ran the turn — no chip for it.
|
|
1311
|
-
|
|
1495
|
+
// provider ran the turn — no chip for it. Nor without a bound provider to
|
|
1496
|
+
// compare with (a shared view of a conversation that has none).
|
|
1497
|
+
const crossProviderTag = (msg.role === 'assistant' && executedProvider && executedProvider !== 'unknown' && boundProvider && executedProvider !== boundProvider)
|
|
1312
1498
|
? ` <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>`
|
|
1313
1499
|
: '';
|
|
1314
1500
|
const usage = (msg.role === 'assistant' && msg?.usage && typeof msg.usage === 'object') ? msg.usage : null;
|
|
@@ -1361,7 +1547,14 @@ function createMessageNode(msg, msgId = null, force = false) {
|
|
|
1361
1547
|
const mainThoughts = thoughts.filter((t) => !t?.subagentRunId);
|
|
1362
1548
|
const activityHtml = mainActivities.length ? renderActivityMarkup(mainActivities, promotedBoundaryEntry) : '';
|
|
1363
1549
|
const thoughtsHtml = mainThoughts.length ? renderThoughtsMarkup(mainThoughts) : '';
|
|
1364
|
-
|
|
1550
|
+
// One row per sub-agent the turn spawned, as in the composer's task panel;
|
|
1551
|
+
// its title opens the agent's whole record. The store's copy wins: status
|
|
1552
|
+
// events keep it current after the bubble was built.
|
|
1553
|
+
rememberSubagentRuns(subagentRuns);
|
|
1554
|
+
const subagentHtml = subagentRowsMarkup(
|
|
1555
|
+
subagentRuns.map((run) => mergeLiveSubagentRun(run, getSubagentRun(run.subagentRunId))),
|
|
1556
|
+
{ clickable: true },
|
|
1557
|
+
);
|
|
1365
1558
|
// Finished background workflows persisted with this assistant message
|
|
1366
1559
|
// (docs/plans/workflow-progress-tree.md, Phase 4). The template only
|
|
1367
1560
|
// reserves a placeholder; the cards are DOM-built below so digest text
|
|
@@ -1389,7 +1582,9 @@ function createMessageNode(msg, msgId = null, force = false) {
|
|
|
1389
1582
|
&& cancellableSteerIdsForConversation(currentConvId).has(msgId);
|
|
1390
1583
|
const showsCancelButton = isQueuedUserMessage || isCancellableSteer;
|
|
1391
1584
|
const isCancelInFlight = showsCancelButton && bubbleCancelInFlight.has(msgId);
|
|
1392
|
-
|
|
1585
|
+
// The conversation's hidden list once it is known (a change on another
|
|
1586
|
+
// device arrives there), else the message's own mark.
|
|
1587
|
+
const hiddenFromShares = isMessageHiddenFromShares(currentConvId, msgId) ?? (msg?.hiddenFromShares === true);
|
|
1393
1588
|
const activeTurnMessageId = String(getActiveTurnForConversation(currentConvId)?.messageId || '').trim();
|
|
1394
1589
|
const sourceMessageId = String(msg?.sourceMessageId || '').trim();
|
|
1395
1590
|
const belongsToActiveTurn = !!activeTurnMessageId
|
|
@@ -1402,8 +1597,8 @@ function createMessageNode(msg, msgId = null, force = false) {
|
|
|
1402
1597
|
const shareVisibilityActionHtml = canToggleShareVisibility
|
|
1403
1598
|
? `<div class="msg-share-visibility">
|
|
1404
1599
|
${buildMessagePinControlsHtml(msgId, pinned)}
|
|
1405
|
-
${hiddenFromShares ?
|
|
1406
|
-
<button type="button" class="msg-share-visibility-btn" data-action="toggle-share-visibility" data-message-id="${escHtml(msgId)}" data-hidden-from-shares="${hiddenFromShares ? 'true' : 'false'}" title="${hiddenFromShares
|
|
1600
|
+
${hiddenFromShares ? HIDDEN_LABEL_HTML : ''}
|
|
1601
|
+
<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>
|
|
1407
1602
|
</div>`
|
|
1408
1603
|
: '';
|
|
1409
1604
|
// A terminal failure whose fix lives in the relay UI gets the fix as a button
|
|
@@ -1440,6 +1635,8 @@ function createMessageNode(msg, msgId = null, force = false) {
|
|
|
1440
1635
|
<div class="msg-label">${label}${modelTag}${reasoningTag}${modeTag}${autoTag}${continuationTag}${crossProviderTag}${usageTurnTag}${usageRemainingTag}${usageStaleTag} · ${fmtDate(msg.timestamp)}</div>`;
|
|
1441
1636
|
|
|
1442
1637
|
const bubble = div.querySelector('.msg-bubble');
|
|
1638
|
+
// A running sub-agent's row keeps its clock going once the bubble is placed.
|
|
1639
|
+
if (subagentHtml.includes('data-started-at')) setTimeout(syncSubagentRowClock, 0);
|
|
1443
1640
|
rewriteLocalAssetUrlsInNode(bubble, { preferDrive: msg.role === 'assistant' });
|
|
1444
1641
|
linkifyWorkspaceMentionsInNode(bubble);
|
|
1445
1642
|
// One collapsed "Finished background task" card per persisted run, sharing
|
|
@@ -1536,21 +1733,12 @@ function prependMessageNodes(msgs) {
|
|
|
1536
1733
|
return { inserted, firstMessageId };
|
|
1537
1734
|
}
|
|
1538
1735
|
|
|
1736
|
+
// A shared view's tool lines arrive filtered by the relay's path privacy
|
|
1737
|
+
// setting (server/services/share-path-redaction.mjs); they are shown as sent.
|
|
1539
1738
|
export function decorateActivityText(text) {
|
|
1540
1739
|
const value = String(text || '').trim();
|
|
1541
1740
|
if (!value) return '';
|
|
1542
|
-
|
|
1543
|
-
const source = String(input || '');
|
|
1544
|
-
const tokenMasked = source.replace(/@(file|folder):([^\s`]+)/gi, (_m, kind, rawPath) => {
|
|
1545
|
-
const normalized = String(rawPath || '').replace(/\\/g, '/').replace(/\/+$/, '');
|
|
1546
|
-
const segments = normalized.split('/').filter(Boolean);
|
|
1547
|
-
const basename = segments[segments.length - 1] || normalized;
|
|
1548
|
-
return `@${String(kind || '').toLowerCase()}:${basename}`;
|
|
1549
|
-
});
|
|
1550
|
-
return tokenMasked.replace(/([A-Za-z]:)?(?:[\\/~.]?[\\/])(?:[^\\/\s]+[\\/])+([^\\/\s]+)/g, (_m, _prefix, basename) => basename);
|
|
1551
|
-
};
|
|
1552
|
-
const sharedSafeValue = IS_SHARED_VIEW ? maskSharedPathSegments(value) : value;
|
|
1553
|
-
return prefixToolActivityEmoji(sharedSafeValue);
|
|
1741
|
+
return prefixToolActivityEmoji(value);
|
|
1554
1742
|
}
|
|
1555
1743
|
|
|
1556
1744
|
export function renderThoughtsMarkup(thoughts) {
|
|
@@ -1648,83 +1836,6 @@ export function renderActivityMarkup(activities, promotedBoundaryEntry = undefin
|
|
|
1648
1836
|
return `${progressHtml}${toolsHtml}`;
|
|
1649
1837
|
}
|
|
1650
1838
|
|
|
1651
|
-
// Persisted nested subagent sections: groups a finished message's activities
|
|
1652
|
-
// and thoughts by subagentRunId and renders one collapsible block per run,
|
|
1653
|
-
// nested by parentSubagentId — mirroring the live sub-bubbles.
|
|
1654
|
-
export function renderSubagentRunsMarkup(subagentRuns, activities, thoughts) {
|
|
1655
|
-
const activityByRun = new Map();
|
|
1656
|
-
for (const item of (Array.isArray(activities) ? activities : [])) {
|
|
1657
|
-
const entry = normalizeRelayActivityEntry(item);
|
|
1658
|
-
if (!entry?.subagentRunId) continue;
|
|
1659
|
-
const list = activityByRun.get(entry.subagentRunId) || [];
|
|
1660
|
-
list.push(entry.text);
|
|
1661
|
-
activityByRun.set(entry.subagentRunId, list);
|
|
1662
|
-
}
|
|
1663
|
-
const thoughtsByRun = new Map();
|
|
1664
|
-
for (const thought of (Array.isArray(thoughts) ? thoughts : [])) {
|
|
1665
|
-
const runId = thought?.subagentRunId ? String(thought.subagentRunId).trim() : '';
|
|
1666
|
-
const text = String(thought?.text || '').trim();
|
|
1667
|
-
if (!runId || !text) continue;
|
|
1668
|
-
const list = thoughtsByRun.get(runId) || [];
|
|
1669
|
-
list.push({ reasoningId: String(thought?.reasoningId || '').trim(), text });
|
|
1670
|
-
thoughtsByRun.set(runId, list);
|
|
1671
|
-
}
|
|
1672
|
-
const runsById = new Map();
|
|
1673
|
-
for (const run of (Array.isArray(subagentRuns) ? subagentRuns : [])) {
|
|
1674
|
-
const runId = String(run?.subagentRunId || '').trim();
|
|
1675
|
-
if (!runId) continue;
|
|
1676
|
-
runsById.set(runId, {
|
|
1677
|
-
subagentRunId: runId,
|
|
1678
|
-
parentSubagentId: run?.parentSubagentId ? String(run.parentSubagentId).trim() : null,
|
|
1679
|
-
displayName: String(run?.displayName || '').trim() || `Subagent ${runId.slice(0, 8)}`,
|
|
1680
|
-
status: String(run?.status || 'completed').trim().toLowerCase(),
|
|
1681
|
-
});
|
|
1682
|
-
}
|
|
1683
|
-
// Items may reference runs whose metadata rows are unavailable; synthesize.
|
|
1684
|
-
for (const runId of new Set([...activityByRun.keys(), ...thoughtsByRun.keys()])) {
|
|
1685
|
-
if (!runsById.has(runId)) {
|
|
1686
|
-
runsById.set(runId, {
|
|
1687
|
-
subagentRunId: runId,
|
|
1688
|
-
parentSubagentId: null,
|
|
1689
|
-
displayName: `Subagent ${runId.slice(0, 8)}`,
|
|
1690
|
-
status: 'completed',
|
|
1691
|
-
});
|
|
1692
|
-
}
|
|
1693
|
-
}
|
|
1694
|
-
if (!runsById.size) return '';
|
|
1695
|
-
const childrenByParent = new Map();
|
|
1696
|
-
const roots = [];
|
|
1697
|
-
for (const run of runsById.values()) {
|
|
1698
|
-
const parentId = run.parentSubagentId && runsById.has(run.parentSubagentId) ? run.parentSubagentId : null;
|
|
1699
|
-
if (parentId) {
|
|
1700
|
-
const children = childrenByParent.get(parentId) || [];
|
|
1701
|
-
children.push(run);
|
|
1702
|
-
childrenByParent.set(parentId, children);
|
|
1703
|
-
} else {
|
|
1704
|
-
roots.push(run);
|
|
1705
|
-
}
|
|
1706
|
-
}
|
|
1707
|
-
const renderRun = (run) => {
|
|
1708
|
-
const runActivities = activityByRun.get(run.subagentRunId) || [];
|
|
1709
|
-
const runThoughts = thoughtsByRun.get(run.subagentRunId) || [];
|
|
1710
|
-
const children = childrenByParent.get(run.subagentRunId) || [];
|
|
1711
|
-
const thoughtsBlock = runThoughts.length
|
|
1712
|
-
? `<details class="msg-thoughts msg-subagent-thoughts"><summary>💭 Thoughts (${runThoughts.length})</summary><div class="msg-thoughts-list">${runThoughts.map((thought) => `<div class="msg-thought-item"${thought.reasoningId ? ` data-reasoning-id="${escHtml(thought.reasoningId)}"` : ''}>${renderMarkdownPreview(thought.text, false)}</div>`).join('')}</div></details>`
|
|
1713
|
-
: '';
|
|
1714
|
-
const activitiesBlock = runActivities.length
|
|
1715
|
-
? `<div class="msg-activity-list">${runActivities.map((text) => `<div class="msg-activity-item">${escHtml(decorateActivityText(text))}</div>`).join('')}</div>`
|
|
1716
|
-
: '';
|
|
1717
|
-
const childrenBlock = children.map(renderRun).join('');
|
|
1718
|
-
const statusLabel = run.status && run.status !== 'completed' ? ` · ${escHtml(run.status)}` : '';
|
|
1719
|
-
return `
|
|
1720
|
-
<details class="msg-activity msg-subagent-run" data-subagent-run-id="${escHtml(run.subagentRunId)}">
|
|
1721
|
-
<summary>🤖 ${escHtml(run.displayName)}${statusLabel}${runActivities.length ? ` (${runActivities.length})` : ''}</summary>
|
|
1722
|
-
${thoughtsBlock}${activitiesBlock}${childrenBlock}
|
|
1723
|
-
</details>`;
|
|
1724
|
-
};
|
|
1725
|
-
return roots.map(renderRun).join('');
|
|
1726
|
-
}
|
|
1727
|
-
|
|
1728
1839
|
// Places the live bubble directly under its owning user message, or leaves a
|
|
1729
1840
|
// bubble already in the transcript where it is when that row is not rendered.
|
|
1730
1841
|
// Idempotent so the reuse path can call it every tick: a bubble built before
|
|
@@ -1745,6 +1856,8 @@ function anchorThinkingBubble(div, messageId) {
|
|
|
1745
1856
|
if (next) el.insertBefore(div, next);
|
|
1746
1857
|
else el.appendChild(div);
|
|
1747
1858
|
if (div.dataset.compactBoundary) syncSeparatorsNow();
|
|
1859
|
+
// A folded agent prompt's row says the turn is working.
|
|
1860
|
+
else syncAgentExchangeFoldsNow();
|
|
1748
1861
|
} else if (div.parentNode !== el) {
|
|
1749
1862
|
el.appendChild(div);
|
|
1750
1863
|
}
|
|
@@ -1820,6 +1933,7 @@ export function showThinking(messageId = null, autoScroll = true) {
|
|
|
1820
1933
|
export function removeThinking() {
|
|
1821
1934
|
thinkingMessageId = null;
|
|
1822
1935
|
lastInFlightSnapshotKey = '';
|
|
1936
|
+
const hadBubble = !!document.getElementById('thinking-indicator');
|
|
1823
1937
|
const wasCompacting = !!document.getElementById('thinking-indicator')?.dataset?.compactBoundary;
|
|
1824
1938
|
unobserveThinkingBubble();
|
|
1825
1939
|
document.getElementById('thinking-indicator')?.remove();
|
|
@@ -1827,6 +1941,45 @@ export function removeThinking() {
|
|
|
1827
1941
|
// caller's same-tick work: a reply appended right after this call takes
|
|
1828
1942
|
// the line over (relabelled in place) instead of it being dropped first.
|
|
1829
1943
|
if (wasCompacting) queueMicrotask(syncSeparatorsNow);
|
|
1944
|
+
// A turn that ended without a reply (cancelled) stops saying "working".
|
|
1945
|
+
if (hadBubble) syncAgentExchangeFoldsNow();
|
|
1946
|
+
}
|
|
1947
|
+
|
|
1948
|
+
// The turn ends under a reader who is not at the end: the live bubble is
|
|
1949
|
+
// taken off the page and the reply row is put in its place. The page is
|
|
1950
|
+
// shorter for a moment (scrollTop can be clamped), and the reply is not as
|
|
1951
|
+
// tall as the bubble was (its thoughts are folded). Whatever the reader looks
|
|
1952
|
+
// at stays where it is: the block of text at the top of the viewport is noted
|
|
1953
|
+
// before the swap and put back after it. Read in the bubble's stream, it is
|
|
1954
|
+
// found again in the reply; in a row above or below the bubble, in that row.
|
|
1955
|
+
export function captureLiveReplyReadingPosition() {
|
|
1956
|
+
const el = getMessagesElement();
|
|
1957
|
+
if (!el) return null;
|
|
1958
|
+
const bubble = document.getElementById('thinking-indicator');
|
|
1959
|
+
return {
|
|
1960
|
+
scrollTop: el.scrollTop,
|
|
1961
|
+
block: captureReadingBlock(el),
|
|
1962
|
+
// Below the bubble the pixel position is the wrong fallback: everything
|
|
1963
|
+
// the reader sees moves up or down with the swap, and scrollTop with it.
|
|
1964
|
+
belowBubble: !!bubble && bubble.getBoundingClientRect().bottom <= el.getBoundingClientRect().top,
|
|
1965
|
+
};
|
|
1966
|
+
}
|
|
1967
|
+
|
|
1968
|
+
export function restoreLiveReplyReadingPosition(position) {
|
|
1969
|
+
const el = getMessagesElement();
|
|
1970
|
+
if (!el || !position || isChatInteractionHeld()) return;
|
|
1971
|
+
if (restoreReadingBlock(el, position.block)) return;
|
|
1972
|
+
if (!position.belowBubble) el.scrollTop = position.scrollTop;
|
|
1973
|
+
}
|
|
1974
|
+
|
|
1975
|
+
// A rebuild of the transcript on the page that nobody asked to scroll: the
|
|
1976
|
+
// reader keeps the text they are at, or failing that the pixel position.
|
|
1977
|
+
function renderMessagesInPlace(msgs, meta) {
|
|
1978
|
+
const el = getMessagesElement();
|
|
1979
|
+
const reading = el ? { scrollTop: el.scrollTop, block: captureReadingBlock(el) } : null;
|
|
1980
|
+
const rendered = renderMessages(msgs, false, meta);
|
|
1981
|
+
if (el && reading && rendered && !restoreReadingBlock(el, reading.block)) el.scrollTop = reading.scrollTop;
|
|
1982
|
+
return rendered;
|
|
1830
1983
|
}
|
|
1831
1984
|
|
|
1832
1985
|
// The live turn's compaction line: the bubble stamps the turn's compaction
|
|
@@ -2045,6 +2198,9 @@ export function restoreInFlightThinking(inFlight, autoScroll = true) {
|
|
|
2045
2198
|
return;
|
|
2046
2199
|
}
|
|
2047
2200
|
lastInFlightSnapshotKey = snapshotKey;
|
|
2201
|
+
// The snapshot may have been read before the newest live frame was stored.
|
|
2202
|
+
const liveStreamSeq = relayStreamStateByMessageId.get(messageId)?.seq || 0;
|
|
2203
|
+
const liveStreamText = relayStreamTextByMessageId.get(messageId);
|
|
2048
2204
|
clearRelayStreamState();
|
|
2049
2205
|
setConversationTurnState(currentConvId, { messageId, status: 'processing' });
|
|
2050
2206
|
const activities = mergeRelayActivityTexts(
|
|
@@ -2086,14 +2242,19 @@ export function restoreInFlightThinking(inFlight, autoScroll = true) {
|
|
|
2086
2242
|
// Seed the per-thread stream text before the bubbles are built so both the
|
|
2087
2243
|
// reply preview and each subagent bubble repaint on reload.
|
|
2088
2244
|
const streamTextByThread = deriveInFlightStreamTextByThread(inFlight);
|
|
2089
|
-
if (streamTextByThread.main) {
|
|
2245
|
+
if (streamTextByThread.main && streamTextByThread.main.seq >= liveStreamSeq) {
|
|
2090
2246
|
relayStreamTextByMessageId.set(messageId, streamTextByThread.main.text);
|
|
2247
|
+
} else if (liveStreamText !== undefined) {
|
|
2248
|
+
relayStreamTextByMessageId.set(messageId, liveStreamText);
|
|
2091
2249
|
}
|
|
2092
2250
|
for (const [runId, entry] of streamTextByThread.bySubagentRunId) {
|
|
2093
2251
|
upsertSubagentRun({ subagentRunId: runId, messageId, conversationId: currentConvId });
|
|
2094
2252
|
setSubagentStreamText(runId, entry.text);
|
|
2095
2253
|
}
|
|
2096
2254
|
showThinking(messageId, autoScroll);
|
|
2255
|
+
// relay_stream frames are volatile, so a dropped one reaches the bubble only
|
|
2256
|
+
// from here; showThinking does not repaint a bubble it reuses.
|
|
2257
|
+
renderThinkingStream();
|
|
2097
2258
|
renderThinkingActivities();
|
|
2098
2259
|
renderThinkingThoughts();
|
|
2099
2260
|
renderRestoredSubagentBubbles(messageId);
|
|
@@ -2578,16 +2739,23 @@ export function applyRelayStreamEvent({
|
|
|
2578
2739
|
if (id !== thinkingMessageId) return true;
|
|
2579
2740
|
if (runId) renderSubagentStream(runId, text);
|
|
2580
2741
|
else renderThinkingStream();
|
|
2742
|
+
// Again once the text is in: it grew below the end the status scrolled to.
|
|
2743
|
+
if (autoScroll && !getThinkingFollowMode()) scrollBottom();
|
|
2581
2744
|
return true;
|
|
2582
2745
|
}
|
|
2583
2746
|
|
|
2584
2747
|
export function flushDeferredMessageRender() {
|
|
2585
2748
|
if (!deferredMessageRender) return;
|
|
2586
|
-
const { msgs,
|
|
2749
|
+
const { msgs, meta } = deferredMessageRender;
|
|
2587
2750
|
deferredMessageRender = null;
|
|
2588
2751
|
const deferredConversationId = String(meta?.conversationId || '').trim();
|
|
2589
2752
|
if (deferredConversationId && deferredConversationId !== String(currentConvId || '').trim()) return;
|
|
2590
|
-
|
|
2753
|
+
// Whoever asked for this render has long returned, and with it whatever
|
|
2754
|
+
// would have put the reader back; and where the reader was when it was
|
|
2755
|
+
// held back is not where they are now. Decided as it runs: at the end it
|
|
2756
|
+
// follows, anywhere else it leaves them at the text they read.
|
|
2757
|
+
if (followsTranscriptEnd()) renderMessages(msgs, true, meta);
|
|
2758
|
+
else renderMessagesInPlace(msgs, meta);
|
|
2591
2759
|
}
|
|
2592
2760
|
|
|
2593
2761
|
// Blacklists the id from further live relay_stream frames. Kept separate from
|
|
@@ -2905,10 +3073,13 @@ export function replaceRenderedMessage(msg, msgId) {
|
|
|
2905
3073
|
seenMessageIds.delete(id);
|
|
2906
3074
|
const fresh = createMessageNode(msg, id, true);
|
|
2907
3075
|
placeholder.replaceWith(fresh || existing);
|
|
3076
|
+
// The rebuilt row takes its merge and fold classes back.
|
|
3077
|
+
if (fresh) syncSeparatorsNow();
|
|
2908
3078
|
return Boolean(fresh);
|
|
2909
3079
|
}
|
|
2910
3080
|
|
|
2911
3081
|
async function resendStoppedSteer(conversationId, markerId) {
|
|
3082
|
+
const send = beginOwnSend();
|
|
2912
3083
|
const conversationKey = String(conversationId || '').trim();
|
|
2913
3084
|
const id = String(markerId || '').trim();
|
|
2914
3085
|
if (!conversationKey || !id || stoppedSteerResends.has(id)) return;
|
|
@@ -2960,7 +3131,7 @@ async function resendStoppedSteer(conversationId, markerId) {
|
|
|
2960
3131
|
mode: selectedMode,
|
|
2961
3132
|
timestamp: new Date().toISOString(),
|
|
2962
3133
|
attachments,
|
|
2963
|
-
},
|
|
3134
|
+
}, ownSendFollows(send), clientMessageId, true);
|
|
2964
3135
|
bubbleAppended = true;
|
|
2965
3136
|
}
|
|
2966
3137
|
const r = await sendMessageApi({
|
|
@@ -3004,7 +3175,7 @@ async function resendStoppedSteer(conversationId, markerId) {
|
|
|
3004
3175
|
showTransientRelayNotice(`Resent without ${dropped} attachment${dropped === 1 ? '' : 's'} that ${dropped === 1 ? 'is' : 'are'} no longer available.`, 7000);
|
|
3005
3176
|
}
|
|
3006
3177
|
if (cliOnline && isCurrentConversation(conversationKey) && !getActiveTurnForConversation(conversationKey)?.messageId) {
|
|
3007
|
-
showThinking(r.messageId || null);
|
|
3178
|
+
showThinking(r.messageId || null, ownSendFollows(send));
|
|
3008
3179
|
}
|
|
3009
3180
|
} catch (error) {
|
|
3010
3181
|
if (!hasSendEchoArrived(clientMessageId)) {
|
|
@@ -3173,6 +3344,35 @@ export function syncRenderedPinState(pinnedIds) {
|
|
|
3173
3344
|
}
|
|
3174
3345
|
}
|
|
3175
3346
|
|
|
3347
|
+
const HIDDEN_LABEL_HTML = '<span class="msg-hidden-label">Hidden from shared viewers</span>';
|
|
3348
|
+
|
|
3349
|
+
function shareVisibilityButtonTitle(hidden) {
|
|
3350
|
+
return hidden ? 'Shows this message in shared conversations' : 'Hides this message from shared conversations';
|
|
3351
|
+
}
|
|
3352
|
+
|
|
3353
|
+
// The hidden list changed (this page, another device): bring the bubbles on
|
|
3354
|
+
// the page in line without rebuilding them. A button whose own round trip is
|
|
3355
|
+
// still out keeps its "Hiding…" until the answer.
|
|
3356
|
+
export function syncRenderedShareVisibility(hiddenIds) {
|
|
3357
|
+
const el = getMessagesElement();
|
|
3358
|
+
if (!el) return;
|
|
3359
|
+
const ids = hiddenIds instanceof Set ? hiddenIds : new Set(hiddenIds || []);
|
|
3360
|
+
for (const button of el.querySelectorAll('.msg-share-visibility-btn')) {
|
|
3361
|
+
const messageId = String(button.dataset.messageId || '').trim();
|
|
3362
|
+
if (shareVisibilityInFlight.has(messageId)) continue;
|
|
3363
|
+
const hidden = ids.has(messageId);
|
|
3364
|
+
if ((button.dataset.hiddenFromShares === 'true') === hidden) continue;
|
|
3365
|
+
button.dataset.hiddenFromShares = hidden ? 'true' : 'false';
|
|
3366
|
+
button.disabled = false;
|
|
3367
|
+
button.textContent = hidden ? 'Unhide' : 'Hide';
|
|
3368
|
+
button.title = shareVisibilityButtonTitle(hidden);
|
|
3369
|
+
const sibling = button.previousElementSibling;
|
|
3370
|
+
const label = sibling?.classList?.contains('msg-hidden-label') ? sibling : null;
|
|
3371
|
+
if (hidden && !label) button.insertAdjacentHTML('beforebegin', HIDDEN_LABEL_HTML);
|
|
3372
|
+
if (!hidden && label) label.remove();
|
|
3373
|
+
}
|
|
3374
|
+
}
|
|
3375
|
+
|
|
3176
3376
|
async function toggleMessageShareVisibility(conversationId, messageId, hiddenFromShares) {
|
|
3177
3377
|
const conversationKey = String(conversationId || '').trim();
|
|
3178
3378
|
const targetMessageId = String(messageId || '').trim();
|
|
@@ -3191,13 +3391,17 @@ async function toggleMessageShareVisibility(conversationId, messageId, hiddenFro
|
|
|
3191
3391
|
button.disabled = false;
|
|
3192
3392
|
button.textContent = hiddenFromShares ? 'Unhide' : 'Hide';
|
|
3193
3393
|
}
|
|
3194
|
-
showTransientRelayNotice('Could not update shared-message visibility.');
|
|
3394
|
+
showTransientRelayNotice(String(result?.error || '').trim() || 'Could not update shared-message visibility.');
|
|
3195
3395
|
return;
|
|
3196
3396
|
}
|
|
3197
3397
|
|
|
3198
3398
|
showTransientRelayNotice(result.hiddenFromShares
|
|
3199
3399
|
? 'Message hidden from shared viewers.'
|
|
3200
3400
|
: 'Message visible to shared viewers.');
|
|
3401
|
+
// Stored under its own conversation: the 🙈 count, even after a switch.
|
|
3402
|
+
if ('hiddenMessages' in result) {
|
|
3403
|
+
setConversationHiddenMessages(conversationKey, result.hiddenMessages, result.hiddenMessagesRevision);
|
|
3404
|
+
}
|
|
3201
3405
|
// The reload below only refreshes the open view; if the user switched
|
|
3202
3406
|
// conversations while the toggle round trip was in flight, rendering it
|
|
3203
3407
|
// would replace the new conversation's messages with this one's.
|
|
@@ -3207,7 +3411,7 @@ async function toggleMessageShareVisibility(conversationId, messageId, hiddenFro
|
|
|
3207
3411
|
});
|
|
3208
3412
|
if (String(currentConvId || '').trim() !== conversationKey) return;
|
|
3209
3413
|
if (response?.messages) {
|
|
3210
|
-
|
|
3414
|
+
renderMessagesInPlace(response.messages, response);
|
|
3211
3415
|
}
|
|
3212
3416
|
}
|
|
3213
3417
|
|
|
@@ -3376,6 +3580,14 @@ export function appendMessage(msg, scroll = true, msgId = null, force = false, i
|
|
|
3376
3580
|
if (empty) empty.remove();
|
|
3377
3581
|
const node = createMessageNode(msg, msgId, force);
|
|
3378
3582
|
const isNewNode = !!node && !node.parentNode;
|
|
3583
|
+
// A reply the socket delivers without its prompt's id in the message (a
|
|
3584
|
+
// failed turn's note) is placed under that prompt, and an agent prompt's
|
|
3585
|
+
// row reads its status from it.
|
|
3586
|
+
const exchangeRow = isNewNode ? node[AGENT_EXCHANGE_ROW_KEY] : null;
|
|
3587
|
+
const anchorId = String(insertAfterId || '').trim();
|
|
3588
|
+
if (exchangeRow?.role === 'assistant' && !exchangeRow.sourceMessageId && anchorId && anchorId !== String(msgId || '').trim()) {
|
|
3589
|
+
exchangeRow.sourceMessageId = anchorId;
|
|
3590
|
+
}
|
|
3379
3591
|
// Insert without scrolling: the separator pass below can add a row above
|
|
3380
3592
|
// this message, and scrolling before it runs leaves the viewport a
|
|
3381
3593
|
// separator-height short of the bottom (which then reads as "not at
|
|
@@ -3491,6 +3703,91 @@ export function noteTranscriptJump() {
|
|
|
3491
3703
|
transcriptJumpEpoch += 1;
|
|
3492
3704
|
}
|
|
3493
3705
|
|
|
3706
|
+
// Counts rows the socket put on the page: another client's message, a reply,
|
|
3707
|
+
// a row rewritten in place. A reload whose request was on its way when one
|
|
3708
|
+
// landed may not have it, and its rebuild would take it off the page again
|
|
3709
|
+
// (transcript-reload-freshness.mjs).
|
|
3710
|
+
let transcriptLiveRowEpoch = 0;
|
|
3711
|
+
|
|
3712
|
+
export function getTranscriptLiveRowEpoch() {
|
|
3713
|
+
return transcriptLiveRowEpoch;
|
|
3714
|
+
}
|
|
3715
|
+
|
|
3716
|
+
export function noteTranscriptLiveRow() {
|
|
3717
|
+
transcriptLiveRowEpoch += 1;
|
|
3718
|
+
}
|
|
3719
|
+
|
|
3720
|
+
function liveConversationCount() {
|
|
3721
|
+
return Object.values(conversations || {}).filter((conversation) => conversation && conversation.archived !== true).length;
|
|
3722
|
+
}
|
|
3723
|
+
|
|
3724
|
+
function runEmptyStateAction(action) {
|
|
3725
|
+
switch (String(action || '').trim()) {
|
|
3726
|
+
case 'open-settings':
|
|
3727
|
+
openSettingsModal('providers');
|
|
3728
|
+
return;
|
|
3729
|
+
case 'new-chat':
|
|
3730
|
+
void window.newConversation?.();
|
|
3731
|
+
return;
|
|
3732
|
+
case 'show-conversations':
|
|
3733
|
+
toggleSidebar(true);
|
|
3734
|
+
return;
|
|
3735
|
+
default:
|
|
3736
|
+
}
|
|
3737
|
+
}
|
|
3738
|
+
|
|
3739
|
+
function buildEmptyStateNode(model) {
|
|
3740
|
+
const node = document.createElement('div');
|
|
3741
|
+
node.className = 'empty-state';
|
|
3742
|
+
node.dataset.variant = model.variant;
|
|
3743
|
+
const actions = model.actions.map((action) => {
|
|
3744
|
+
const classes = ['empty-state-btn'];
|
|
3745
|
+
if (action.primary) classes.push('empty-state-btn-accent');
|
|
3746
|
+
if (action.link) classes.push('empty-state-link');
|
|
3747
|
+
return `<button type="button" id="${escHtml(action.id)}" class="${classes.join(' ')}" data-empty-action="${escHtml(action.action)}">${escHtml(action.label)}</button>`;
|
|
3748
|
+
}).join('');
|
|
3749
|
+
node.innerHTML = `
|
|
3750
|
+
<div class="icon">${escHtml(model.icon)}</div>
|
|
3751
|
+
<h3>${escHtml(model.heading)}</h3>
|
|
3752
|
+
<p>${escHtml(model.text)}</p>
|
|
3753
|
+
<div class="empty-state-actions">${actions}</div>`;
|
|
3754
|
+
for (const button of node.querySelectorAll('[data-empty-action]')) {
|
|
3755
|
+
button.addEventListener('click', (event) => {
|
|
3756
|
+
event.preventDefault();
|
|
3757
|
+
runEmptyStateAction(button.dataset.emptyAction);
|
|
3758
|
+
});
|
|
3759
|
+
}
|
|
3760
|
+
return node;
|
|
3761
|
+
}
|
|
3762
|
+
|
|
3763
|
+
/**
|
|
3764
|
+
* Draws the "no conversation open" screen into the transcript, or redraws it
|
|
3765
|
+
* when readiness or the conversation list changed. A no-op with a
|
|
3766
|
+
* conversation open, in the shared view, or while the status view owns the
|
|
3767
|
+
* transcript. Idempotent: the same model leaves the node alone.
|
|
3768
|
+
*/
|
|
3769
|
+
export function renderNoConversationState() {
|
|
3770
|
+
if (IS_SHARED_VIEW) return false;
|
|
3771
|
+
if (String(currentConvId || '').trim()) return false;
|
|
3772
|
+
const el = getMessagesElement();
|
|
3773
|
+
if (!el || el.querySelector('.status-view')) return false;
|
|
3774
|
+
const existing = el.querySelector('.empty-state');
|
|
3775
|
+
// A transcript with rows in it belongs to something else (a render in flight).
|
|
3776
|
+
if (!existing && el.children.length) return false;
|
|
3777
|
+
const model = resolveNoConversationState({
|
|
3778
|
+
readiness: getProviderReadiness(),
|
|
3779
|
+
conversationCount: liveConversationCount(),
|
|
3780
|
+
mobile: isMobileComposerViewport(),
|
|
3781
|
+
});
|
|
3782
|
+
const key = JSON.stringify(model);
|
|
3783
|
+
if (existing && existing.dataset.emptyStateKey === key) return true;
|
|
3784
|
+
const node = buildEmptyStateNode(model);
|
|
3785
|
+
node.dataset.emptyStateKey = key;
|
|
3786
|
+
if (existing) existing.replaceWith(node);
|
|
3787
|
+
else el.appendChild(node);
|
|
3788
|
+
return true;
|
|
3789
|
+
}
|
|
3790
|
+
|
|
3494
3791
|
export function renderMessages(msgs, scroll = true, meta = {}) {
|
|
3495
3792
|
const el = getMessagesElement();
|
|
3496
3793
|
if (!el) return false;
|
|
@@ -3571,11 +3868,17 @@ export function renderMessages(msgs, scroll = true, meta = {}) {
|
|
|
3571
3868
|
&& thinkingNode.dataset.conversationId === renderConversationKey) ? thinkingNode : null;
|
|
3572
3869
|
if (thinkingNodeInTranscript && !preservedThinkingNode) removeThinking();
|
|
3573
3870
|
if (!ordered.length && !preservedPendingNodes.length && !preservedThinkingNode) {
|
|
3574
|
-
|
|
3575
|
-
|
|
3576
|
-
|
|
3577
|
-
|
|
3578
|
-
|
|
3871
|
+
if (!currentConvId && !IS_SHARED_VIEW) {
|
|
3872
|
+
// Nothing open: the screen says what can be done here (no-conversation-state.mjs).
|
|
3873
|
+
el.innerHTML = '';
|
|
3874
|
+
renderNoConversationState();
|
|
3875
|
+
} else {
|
|
3876
|
+
el.innerHTML = `<div class="empty-state">
|
|
3877
|
+
<div class="icon">${currentConvId ? '💬' : '🚀'}</div>
|
|
3878
|
+
<h3>${currentConvId ? 'No messages yet' : 'New Conversation'}</h3>
|
|
3879
|
+
<p>${currentConvId ? 'Start the conversation below' : 'Type your first message below'}</p>
|
|
3880
|
+
</div>`;
|
|
3881
|
+
}
|
|
3579
3882
|
resetConversationHistoryState();
|
|
3580
3883
|
renderRelayQuestions();
|
|
3581
3884
|
renderRelayBoards();
|
|
@@ -3680,6 +3983,8 @@ export function focusConversationMessageById(messageId, { behavior = 'smooth', b
|
|
|
3680
3983
|
if (!id) return false;
|
|
3681
3984
|
const el = getMessagesElement();
|
|
3682
3985
|
if (!el) return false;
|
|
3986
|
+
// A folded agent prompt is opened before it is scrolled to.
|
|
3987
|
+
revealAgentExchangeMessage(id);
|
|
3683
3988
|
const target = el.querySelector(`.msg[data-message-id="${CSS.escape(id)}"]`);
|
|
3684
3989
|
if (!target) return false;
|
|
3685
3990
|
target.scrollIntoView({ behavior, block, inline: 'nearest' });
|
|
@@ -3721,6 +4026,9 @@ export function compactCurrentConversation() {
|
|
|
3721
4026
|
let unknownCommandWarned = null;
|
|
3722
4027
|
|
|
3723
4028
|
export async function sendMessage() {
|
|
4029
|
+
// The reader's own send, as of now: what follows it scrolls to the end
|
|
4030
|
+
// only while they have not scrolled since.
|
|
4031
|
+
const send = beginOwnSend();
|
|
3724
4032
|
const input = document.getElementById('msg-input');
|
|
3725
4033
|
const originalComposerText = String(input?.value || '');
|
|
3726
4034
|
const text = originalComposerText.trim();
|
|
@@ -3733,6 +4041,13 @@ export async function sendMessage() {
|
|
|
3733
4041
|
return;
|
|
3734
4042
|
}
|
|
3735
4043
|
if (!hasDraft) return;
|
|
4044
|
+
// No conversation open and no provider ready: a send would create a
|
|
4045
|
+
// Copilot chat that cannot run. The composer is locked in this state; this
|
|
4046
|
+
// guard covers a programmatic send, before any upload starts.
|
|
4047
|
+
if (!String(currentConvId || '').trim() && isProviderSetupRequired()) {
|
|
4048
|
+
showTransientRelayNotice('Set up a provider in Settings → Providers before starting a chat.', 6000);
|
|
4049
|
+
return;
|
|
4050
|
+
}
|
|
3736
4051
|
closeSlashAutocomplete();
|
|
3737
4052
|
closeMentionAutocomplete();
|
|
3738
4053
|
|
|
@@ -3754,7 +4069,7 @@ export async function sendMessage() {
|
|
|
3754
4069
|
releaseComposerFocusAfterSend(input);
|
|
3755
4070
|
void flushConversationDraft(currentConvId);
|
|
3756
4071
|
compactCurrentConversation();
|
|
3757
|
-
|
|
4072
|
+
scrollAfterOwnSend(send);
|
|
3758
4073
|
return;
|
|
3759
4074
|
}
|
|
3760
4075
|
|
|
@@ -3831,8 +4146,10 @@ export async function sendMessage() {
|
|
|
3831
4146
|
input.value = '';
|
|
3832
4147
|
autoResize(input);
|
|
3833
4148
|
releaseComposerFocusAfterSend(input);
|
|
3834
|
-
|
|
3835
|
-
|
|
4149
|
+
// Asked before the bubble is on the page.
|
|
4150
|
+
const follow = ownSendFollows(send);
|
|
4151
|
+
appendMessage({ role: 'user', text, model: selectedModel, mode: selectedMode, timestamp: msgTimestamp, attachments }, follow, clientMessageId, true);
|
|
4152
|
+
scrollAfterOwnSend(send);
|
|
3836
4153
|
}
|
|
3837
4154
|
|
|
3838
4155
|
const body = {
|
|
@@ -3920,7 +4237,8 @@ export async function sendMessage() {
|
|
|
3920
4237
|
await window.openConversation?.(r.compactedConversationId);
|
|
3921
4238
|
removeComposerAttachments(draftAttachments);
|
|
3922
4239
|
if (!mobileSend) input.focus();
|
|
3923
|
-
|
|
4240
|
+
// The send goes on in the conversation the compaction opened.
|
|
4241
|
+
scrollAfterOwnSend({ ...send, conversationId: String(r.compactedConversationId || '').trim() });
|
|
3924
4242
|
}
|
|
3925
4243
|
return;
|
|
3926
4244
|
}
|
|
@@ -3941,6 +4259,7 @@ export async function sendMessage() {
|
|
|
3941
4259
|
if (adoptNewConversation) {
|
|
3942
4260
|
setCurrentConv(r.conversationId);
|
|
3943
4261
|
composerConversationId = r.conversationId;
|
|
4262
|
+
nameOwnSendConversation(send, r.conversationId);
|
|
3944
4263
|
}
|
|
3945
4264
|
conversations[r.conversationId] = {
|
|
3946
4265
|
id: r.conversationId,
|
|
@@ -3987,7 +4306,7 @@ export async function sendMessage() {
|
|
|
3987
4306
|
// pinned to the newest message. Re-pin now rather than after the draft
|
|
3988
4307
|
// save's round trip below, or the new bubble sits under the composer
|
|
3989
4308
|
// for as long as that request takes.
|
|
3990
|
-
if (viewingSendConversation()) scrollBottom();
|
|
4309
|
+
if (viewingSendConversation() && ownSendFollows(send)) scrollBottom();
|
|
3991
4310
|
const persistedConversationId = String(r.conversationId || targetConversationId || '').trim();
|
|
3992
4311
|
if (persistedConversationId) {
|
|
3993
4312
|
restorableDraftByConversation.delete(persistedConversationId);
|
|
@@ -4019,7 +4338,7 @@ export async function sendMessage() {
|
|
|
4019
4338
|
// A turn already running here owns the live bubble; adopting the
|
|
4020
4339
|
// just-queued id would re-anchor it under the newest user message.
|
|
4021
4340
|
const activeTurn = getActiveTurnForConversation(composerConversationId);
|
|
4022
|
-
if (!activeTurn?.messageId) showThinking(r.messageId || null);
|
|
4341
|
+
if (!activeTurn?.messageId) showThinking(r.messageId || null, ownSendFollows(send));
|
|
4023
4342
|
}
|
|
4024
4343
|
|
|
4025
4344
|
// Only what this message took leaves the composer: attachments added (or
|
|
@@ -4028,7 +4347,7 @@ export async function sendMessage() {
|
|
|
4028
4347
|
if (viewingSendConversation()) {
|
|
4029
4348
|
removeComposerAttachments(draftAttachments);
|
|
4030
4349
|
if (!mobileSend) input.focus();
|
|
4031
|
-
|
|
4350
|
+
scrollAfterOwnSend(send);
|
|
4032
4351
|
}
|
|
4033
4352
|
} catch (e) {
|
|
4034
4353
|
const sendLanded = hasSendEchoArrived(clientMessageId);
|