@oar-sh/oar 0.9.8 → 0.9.10
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 +478 -0
- package/README.md +129 -39
- package/bin/bat/oar.bat +46 -46
- package/bin/oar.js +814 -228
- package/package.json +5 -6
- package/server/README.md +306 -4
- package/server/claude-worker/claude-remote-relay-tool.mjs +10 -0
- package/server/claude-worker/claude-session-process.mjs +35 -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 +3 -1
- package/server/cursor-worker/cursor-sdk-adapter.mjs +56 -2
- package/server/db-schema.mjs +44 -0
- package/server/migrations/0008-message-pins.mjs +85 -0
- 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/public/app/agent-exchange-fold-view.mjs +248 -0
- package/server/public/app/api-client.js +91 -0
- package/server/public/app/archive-confirm.mjs +28 -0
- package/server/public/app/ask-user-view.js +3 -0
- package/server/public/app/attachments-view.js +1024 -131
- package/server/public/app/back-guard.mjs +58 -0
- package/server/public/app/background-tasks-view.mjs +18 -26
- package/server/public/app/bootstrap.js +230 -35
- package/server/public/app/chat-actions-menu-state.mjs +116 -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/conversation-view.js +471 -110
- package/server/public/app/explorer-edit-state.mjs +68 -0
- package/server/public/app/external-link-policy.mjs +4 -1
- package/server/public/app/file-version.mjs +58 -0
- package/server/public/app/host-suspend-ui.js +6 -3
- package/server/public/app/journal-view.js +161 -27
- package/server/public/app/no-conversation-state.mjs +58 -0
- package/server/public/app/pinned-messages-view.mjs +253 -0
- package/server/public/app/provider-readiness.mjs +120 -0
- package/server/public/app/pwa-install.js +1 -1
- package/server/public/app/relay-board-view.js +3 -0
- package/server/public/app/relay-dot-state.mjs +8 -1
- 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/router.js +8 -2
- package/server/public/app/settings-modal.js +53 -1
- package/server/public/app/socket-handlers.js +65 -28
- package/server/public/app/store.js +25 -2
- 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 +534 -0
- package/server/public/app/task-row-format.mjs +29 -0
- package/server/public/app/thinking-follow.js +4 -0
- package/server/public/app/transcript-jump-bottom-view.mjs +290 -0
- package/server/public/app/transcript-jump-bottom.mjs +99 -0
- package/server/public/app/transcript-reload-freshness.mjs +25 -0
- package/server/public/app/update-ui.js +1 -1
- package/server/public/app/viewer-chat-gallery.mjs +59 -0
- package/server/public/app/viewer-controls-state.mjs +59 -0
- package/server/public/app/viewer-gallery.mjs +46 -0
- package/server/public/app/viewer-gestures.mjs +105 -0
- package/server/public/app/viewer-history.mjs +45 -0
- package/server/public/app/viewer-tap.mjs +112 -0
- package/server/public/index.html +700 -59
- package/server/repositories/message-pin-statements.mjs +39 -0
- package/server/repositories/message-repository.mjs +34 -3
- package/server/repositories/question-repository.mjs +9 -3
- package/server/repositories/remote-relay-repository.mjs +6 -2
- package/server/repositories/subagent-timeline-statements.mjs +170 -0
- package/server/routes/ask-user-routes.mjs +3 -0
- package/server/routes/messages-routes.mjs +548 -111
- package/server/routes/relay-event-routes.mjs +157 -0
- package/server/routes/remote-relay-routes.mjs +15 -0
- package/server/routes/sessions-routes.mjs +164 -24
- package/server/server-runtime.mjs +274 -62
- package/server/services/cloudflared-tunnel-service.mjs +139 -41
- package/server/services/explorer-entry-name.mjs +26 -0
- package/server/services/explorer-mutations.mjs +122 -0
- package/server/services/file-serving.mjs +155 -0
- package/server/services/message-pin-list.mjs +52 -0
- package/server/services/oar-cli-helpers.mjs +139 -4
- package/server/services/oar-cli-menu.mjs +240 -0
- package/server/services/oar-cli-wsl.mjs +129 -0
- package/server/services/preview-proxy-server.mjs +41 -23
- package/server/services/push-dispatch-service.mjs +18 -0
- package/server/services/relay-event-link-service.mjs +289 -0
- package/server/services/relay-event-service.mjs +1935 -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 +494 -2
- package/server/services/remote-relay-registry-service.mjs +11 -1
- package/server/services/safe-served-content.mjs +61 -11
- package/server/services/session-activity-tracker.mjs +487 -0
- package/server/services/session-history-refresh-service.mjs +31 -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/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/upgrade-socket-guard.mjs +16 -0
- package/shared/relay-events.mjs +371 -0
- package/shared/remote-relay-contract.mjs +157 -5
- package/shared/subagent-record-retention.mjs +31 -0
- package/shared/subagent-timeline.mjs +79 -0
|
@@ -47,18 +47,37 @@ import {
|
|
|
47
47
|
getSessionWorkerState,
|
|
48
48
|
openSummaryModal,
|
|
49
49
|
closeSummaryModal,
|
|
50
|
+
parseTimestampMs,
|
|
51
|
+
relayQuestions,
|
|
52
|
+
relayBoards,
|
|
53
|
+
toggleSidebar,
|
|
50
54
|
} from './store.js';
|
|
51
55
|
import { getPendingQuestionCountsByConversation } from './ask-user-view.js';
|
|
52
56
|
import { sendMessage as sendMessageApi, cancelConversationTurn, cancelQueuedConversationTurn, cancelSubagentRun, compactConversation as compactConversationApi, scheduleContextUsageRefresh, loadConversation as loadConversationApi, loadSharedConversation, updateConversationDraft as updateConversationDraftApi, updateMessageShareVisibility, launchSessionWorker } from './api-client.js';
|
|
53
57
|
import { enqueueOutboxRequest, registerOutboxSync } from './sync-outbox.mjs';
|
|
54
58
|
import { linkifyWorkspaceMentionsInNode, parseAppFileHref, renderMarkdownPreview, rewriteLocalAssetUrlsInNode } from './router.js';
|
|
55
|
-
import { renderAttachmentMarkup, removeComposerAttachments, uploadAttachments, setComposerAttachments, setRepoBrowserSessionInfo,
|
|
59
|
+
import { renderAttachmentMarkup, removeComposerAttachments, uploadAttachments, setComposerAttachments, setRepoBrowserSessionInfo, openGalleryItem } from './attachments-view.js';
|
|
60
|
+
import { chatMediaGallery, chatMediaItemFromNode } from './viewer-chat-gallery.mjs';
|
|
56
61
|
import { buildWorkflowRunCard } from './background-tasks-view.mjs';
|
|
62
|
+
import { mergeLiveSubagentRun, subagentRowsMarkup } from './subagent-row.mjs';
|
|
63
|
+
import { rememberSubagentRuns, syncSubagentRowClock } from './subagent-viewer.mjs';
|
|
64
|
+
import { isMessagePinned, toggleMessagePin } from './pinned-messages-view.mjs';
|
|
57
65
|
import { parsePreviewCommand, runPreviewCommand } from './preview-command.mjs';
|
|
58
66
|
import { buildTranscriptPreviewCard } from './preview-cards.mjs';
|
|
59
67
|
import { closeSlashAutocomplete, handleSlashAutocompleteKey, updateSlashAutocomplete } from './slash-autocomplete.mjs';
|
|
60
68
|
import { closeMentionAutocomplete, handleMentionAutocompleteKey, updateMentionAutocomplete } from './mention-autocomplete.mjs';
|
|
61
69
|
import { renderRemoteOriginBadgeHtml } from './remote-relay-origin-view.mjs';
|
|
70
|
+
import { isRelayEventMessage, renderRelayEventRowHtml } from './relay-event-view.mjs';
|
|
71
|
+
import {
|
|
72
|
+
AGENT_EXCHANGE_CLOSED_CLASS,
|
|
73
|
+
AGENT_EXCHANGE_HEAD_CLASS,
|
|
74
|
+
AGENT_EXCHANGE_MEMBER_CLASS,
|
|
75
|
+
agentExchangeFoldOpen,
|
|
76
|
+
agentExchangeRowOf,
|
|
77
|
+
agentExchangeSummary,
|
|
78
|
+
planAgentExchangeFolds,
|
|
79
|
+
renderAgentExchangeRowHtml,
|
|
80
|
+
} from './agent-exchange-fold-view.mjs';
|
|
62
81
|
import { stripRemoteRelayDisplayArtifacts } from './relay-prompt-sanitizer.mjs';
|
|
63
82
|
import { evaluateUnknownCommandGuard } from './slash-commands.mjs';
|
|
64
83
|
import { attachCodeCopyButtons } from './code-copy.mjs';
|
|
@@ -96,9 +115,11 @@ import {
|
|
|
96
115
|
syncSeparatorRail,
|
|
97
116
|
syncTranscriptSeparators,
|
|
98
117
|
} from './transcript-separators.mjs';
|
|
99
|
-
import { ABSORBED_MSG_CLASS, STEER_MARKER_CLASS_BY_KIND, syncSteeredTurnMerge } from './steered-turn-merge.mjs';
|
|
118
|
+
import { ABSORBED_MSG_CLASS, STEERED_MSG_CLASS, STEER_MARKER_CLASS_BY_KIND, syncSteeredTurnMerge } from './steered-turn-merge.mjs';
|
|
100
119
|
import { resolveSubagentFold, subagentFoldSummary, toggledSubagentFoldChoice } from './subagent-fold.mjs';
|
|
101
120
|
import { deriveComposerControlState, hasComposerDraft, hasUploadingAttachments } from './composer-control-state.mjs';
|
|
121
|
+
import { resolveNoConversationState } from './no-conversation-state.mjs';
|
|
122
|
+
import { getProviderReadiness, isProviderSetupRequired } from './provider-readiness.mjs';
|
|
102
123
|
import { buildLiveMessageFingerprint } from './live-message-dedupe.mjs';
|
|
103
124
|
import { createInfiniteLoader } from './infinite-loader.js';
|
|
104
125
|
import { normalizeDraftTimestampMs, isIncomingDraftTimestampStale } from './conversation-draft-timestamp-utils.mjs';
|
|
@@ -148,6 +169,8 @@ function syncSeparatorsNow() {
|
|
|
148
169
|
if (!el) return;
|
|
149
170
|
syncTranscriptSeparators(el);
|
|
150
171
|
syncSteeredTurnMerge(el);
|
|
172
|
+
// After the merge pass: a steered row's class is one of its inputs.
|
|
173
|
+
syncAgentExchangeFolds(el);
|
|
151
174
|
observeTranscriptResize(el);
|
|
152
175
|
}
|
|
153
176
|
|
|
@@ -263,6 +286,7 @@ export function jumpToImageParent(messageId) {
|
|
|
263
286
|
showTransientRelayNotice('Load earlier messages to view the source image.');
|
|
264
287
|
return;
|
|
265
288
|
}
|
|
289
|
+
revealAgentExchangeMessage(id);
|
|
266
290
|
node.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
267
291
|
node.classList.add('message-highlight');
|
|
268
292
|
window.setTimeout(() => node.classList.remove('message-highlight'), 1400);
|
|
@@ -353,6 +377,8 @@ const conversationHistoryLoader = createInfiniteLoader({
|
|
|
353
377
|
// A steering pair split across the page boundary heals here (the absorbed
|
|
354
378
|
// reply just arrived above its already-rendered steered user row).
|
|
355
379
|
syncSteeredTurnMerge(el);
|
|
380
|
+
// The page's agent exchanges fold before the height delta is measured.
|
|
381
|
+
syncAgentExchangeFolds(el);
|
|
356
382
|
requestAnimationFrame(() => {
|
|
357
383
|
if (!el || String(currentConvId || '').trim() !== currentId) return;
|
|
358
384
|
const nextScrollHeight = el.scrollHeight;
|
|
@@ -553,11 +579,28 @@ function syncSendButtonState() {
|
|
|
553
579
|
steeringHeld: steeringHold.held,
|
|
554
580
|
steeringHoldReason: steeringHold.reason,
|
|
555
581
|
archived: conversations[String(currentConvId || '').trim()]?.archived === true,
|
|
582
|
+
setupRequired: !String(currentConvId || '').trim() && isProviderSetupRequired(),
|
|
556
583
|
});
|
|
557
584
|
btn.disabled = state.disabled;
|
|
558
585
|
btn.dataset.action = state.action;
|
|
559
586
|
btn.textContent = state.label;
|
|
560
587
|
btn.title = state.title;
|
|
588
|
+
// The textarea follows: locked with the setup placeholder while no provider
|
|
589
|
+
// is ready, given back the moment readiness flips or a chat opens. The
|
|
590
|
+
// shared view owns its own read-only field and is left alone.
|
|
591
|
+
const input = document.getElementById('msg-input');
|
|
592
|
+
if (input && !IS_SHARED_VIEW) {
|
|
593
|
+
const lock = state.composerDisabled === true;
|
|
594
|
+
if (lock) {
|
|
595
|
+
input.disabled = true;
|
|
596
|
+
input.placeholder = state.placeholder;
|
|
597
|
+
input.dataset.setupLocked = '1';
|
|
598
|
+
} else if (input.dataset.setupLocked === '1') {
|
|
599
|
+
delete input.dataset.setupLocked;
|
|
600
|
+
input.disabled = false;
|
|
601
|
+
window.syncComposerPlaceholder?.();
|
|
602
|
+
}
|
|
603
|
+
}
|
|
561
604
|
}
|
|
562
605
|
|
|
563
606
|
// Button-only refresh for status, poll and question paths. It must never save
|
|
@@ -1224,6 +1267,178 @@ function splitVariantId(modelVariantId = '') {
|
|
|
1224
1267
|
};
|
|
1225
1268
|
}
|
|
1226
1269
|
|
|
1270
|
+
// The user's fold of an event row, by message id: a re-render of the
|
|
1271
|
+
// transcript must not open a row the user folded (or fold one they opened).
|
|
1272
|
+
const relayEventFoldChoices = new Map();
|
|
1273
|
+
|
|
1274
|
+
function createRelayEventNode(msg, msgId) {
|
|
1275
|
+
const div = document.createElement('div');
|
|
1276
|
+
div.className = 'msg relay-event';
|
|
1277
|
+
if (msgId) div.dataset.messageId = msgId;
|
|
1278
|
+
const fingerprint = buildLiveMessageFingerprint({ ...msg, id: msgId || msg?.id || '' });
|
|
1279
|
+
div.dataset.messageRole = fingerprint.role || '';
|
|
1280
|
+
div.dataset.messageTextFingerprint = fingerprint.text || '';
|
|
1281
|
+
div.dataset.messageTimestamp = String(msg?.timestamp || '').trim();
|
|
1282
|
+
div.innerHTML = renderRelayEventRowHtml(msg, { escHtml, timeText: msg?.timestamp ? fmtDate(msg.timestamp) : '' });
|
|
1283
|
+
const row = div.querySelector('details');
|
|
1284
|
+
if (row && msgId) {
|
|
1285
|
+
if (relayEventFoldChoices.has(msgId)) row.open = relayEventFoldChoices.get(msgId);
|
|
1286
|
+
row.addEventListener('toggle', () => relayEventFoldChoices.set(msgId, row.open));
|
|
1287
|
+
}
|
|
1288
|
+
setAgentExchangeRow(div, msg, msgId);
|
|
1289
|
+
return div;
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
// Agent exchanges (agent-exchange-fold-view.mjs): an agent's prompt and this
|
|
1293
|
+
// session's reply fold into one row. What the fold pass needs of a message is
|
|
1294
|
+
// kept on its node when the row is built, so the pass reads the transcript
|
|
1295
|
+
// itself and not the payload it came from.
|
|
1296
|
+
const AGENT_EXCHANGE_ROW_KEY = '__agentExchangeRow';
|
|
1297
|
+
// The user's toggle of an exchange, by its prompt's id: true opened, false
|
|
1298
|
+
// closed, unset none (agentExchangeFoldOpen: open while it works, else
|
|
1299
|
+
// folded). Kept through any re-render.
|
|
1300
|
+
const agentExchangeFoldChoices = new Map();
|
|
1301
|
+
// Exchanges shown for the user while this page lives: a turn that had an open
|
|
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();
|
|
1308
|
+
let agentExchangeStatusByPromptId = new Map();
|
|
1309
|
+
|
|
1310
|
+
function setAgentExchangeRow(div, msg, msgId) {
|
|
1311
|
+
div[AGENT_EXCHANGE_ROW_KEY] = agentExchangeRowOf(msg, { id: msgId || msg?.id || '', parseTimestamp: parseTimestampMs });
|
|
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;
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
// The rows the plan reads, in order, and the node of each. The live bubble
|
|
1327
|
+
// is the running turn's row; cards and boards without a reply are not rows.
|
|
1328
|
+
function agentExchangeTranscriptRows(el) {
|
|
1329
|
+
const rows = [];
|
|
1330
|
+
const nodes = new Map();
|
|
1331
|
+
for (const node of el.children) {
|
|
1332
|
+
if (!node.classList?.contains('msg')) continue;
|
|
1333
|
+
let row = null;
|
|
1334
|
+
if (node.id === 'thinking-indicator') {
|
|
1335
|
+
const id = String(node.dataset.messageId || '').trim();
|
|
1336
|
+
if (id) row = { id, role: 'assistant', live: true };
|
|
1337
|
+
} else if (node[AGENT_EXCHANGE_ROW_KEY]) {
|
|
1338
|
+
row = { ...node[AGENT_EXCHANGE_ROW_KEY], steered: node.classList.contains(STEERED_MSG_CLASS) };
|
|
1339
|
+
}
|
|
1340
|
+
if (!row) continue;
|
|
1341
|
+
rows.push(row);
|
|
1342
|
+
nodes.set(row, node);
|
|
1343
|
+
}
|
|
1344
|
+
return { rows, nodes };
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
/**
|
|
1348
|
+
* Fold the transcript's agent exchanges. One idempotent pass, run after every
|
|
1349
|
+
* render and update of the list like the separator and merge passes. The
|
|
1350
|
+
* folded row lives in the prompt's own node, as its first child, so no
|
|
1351
|
+
* insertion path has to place it; closed, classes hide the prompt's bubble and
|
|
1352
|
+
* the exchange's other rows, which stay in the list and go on updating.
|
|
1353
|
+
*/
|
|
1354
|
+
function syncAgentExchangeFolds(el = getMessagesElement()) {
|
|
1355
|
+
if (!el) return;
|
|
1356
|
+
const { rows, nodes } = agentExchangeTranscriptRows(el);
|
|
1357
|
+
const plan = planAgentExchangeFolds(rows, {
|
|
1358
|
+
runningIds: [getActiveTurnForConversation(currentConvId)?.messageId],
|
|
1359
|
+
openCardIds: openCardMessageIds(),
|
|
1360
|
+
keptShownIds: agentExchangesShownForUser,
|
|
1361
|
+
});
|
|
1362
|
+
const states = new Map();
|
|
1363
|
+
const promptIdByMessageId = new Map();
|
|
1364
|
+
const statusByPromptId = new Map();
|
|
1365
|
+
for (const group of plan) {
|
|
1366
|
+
if (group.transient) agentExchangesShownForUser.add(group.promptId);
|
|
1367
|
+
if (!group.fold) continue;
|
|
1368
|
+
const open = agentExchangeFoldOpen(group.status, agentExchangeFoldChoices.get(group.promptId));
|
|
1369
|
+
states.set(nodes.get(group.prompt), { group, head: true, open });
|
|
1370
|
+
promptIdByMessageId.set(group.promptId, group.promptId);
|
|
1371
|
+
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
|
+
}
|
|
1377
|
+
agentExchangePromptIdByMessageId = promptIdByMessageId;
|
|
1378
|
+
agentExchangeStatusByPromptId = statusByPromptId;
|
|
1379
|
+
for (const node of el.children) {
|
|
1380
|
+
if (node.classList?.contains('msg')) applyAgentExchangeState(node, states.get(node) || null);
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
// For the paths that change the list outside a render: the live bubble, a
|
|
1385
|
+
// card opening. A bulk render runs the pass once at its end.
|
|
1386
|
+
export function syncAgentExchangeFoldsNow() {
|
|
1387
|
+
if (separatorSyncSuspended > 0) return;
|
|
1388
|
+
syncAgentExchangeFolds();
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
function applyAgentExchangeState(node, state) {
|
|
1392
|
+
node.classList.toggle(AGENT_EXCHANGE_HEAD_CLASS, !!state?.head);
|
|
1393
|
+
node.classList.toggle(AGENT_EXCHANGE_MEMBER_CLASS, !!state && !state.head);
|
|
1394
|
+
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
|
+
const existing = node.querySelector(':scope > .agent-exchange-row');
|
|
1399
|
+
if (!state?.head) {
|
|
1400
|
+
existing?.remove();
|
|
1401
|
+
return;
|
|
1402
|
+
}
|
|
1403
|
+
const { group, open } = state;
|
|
1404
|
+
const summary = agentExchangeSummary(group.prompt.origin, group.status, { conversations });
|
|
1405
|
+
const timeText = fmtDate(node.dataset.messageTimestamp);
|
|
1406
|
+
const key = JSON.stringify([summary.text, timeText]);
|
|
1407
|
+
let row = existing;
|
|
1408
|
+
if (!row || row.dataset.summaryKey !== key) {
|
|
1409
|
+
const holder = document.createElement('div');
|
|
1410
|
+
holder.innerHTML = renderAgentExchangeRowHtml(summary, { escHtml, timeText, open });
|
|
1411
|
+
const fresh = holder.firstElementChild;
|
|
1412
|
+
fresh.dataset.summaryKey = key;
|
|
1413
|
+
fresh.dataset.promptId = group.promptId;
|
|
1414
|
+
fresh.addEventListener('toggle', () => onAgentExchangeToggle(fresh));
|
|
1415
|
+
if (row) row.replaceWith(fresh);
|
|
1416
|
+
else node.insertBefore(fresh, node.firstChild);
|
|
1417
|
+
row = fresh;
|
|
1418
|
+
}
|
|
1419
|
+
if (row.open !== open) row.open = open;
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
function onAgentExchangeToggle(row) {
|
|
1423
|
+
const promptId = String(row.dataset.promptId || '').trim();
|
|
1424
|
+
// The pass setting `open` fires this too, and a row it has since replaced
|
|
1425
|
+
// (its status changed) fires it late; only the user's toggle is news.
|
|
1426
|
+
if (!promptId || !row.isConnected) return;
|
|
1427
|
+
const shown = agentExchangeFoldOpen(agentExchangeStatusByPromptId.get(promptId), agentExchangeFoldChoices.get(promptId));
|
|
1428
|
+
if (shown === row.open) return;
|
|
1429
|
+
agentExchangeFoldChoices.set(promptId, row.open);
|
|
1430
|
+
syncAgentExchangeFolds();
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
// A search result, a pin or a jump inside a folded exchange opens it first:
|
|
1434
|
+
// a hidden row has nowhere to scroll to.
|
|
1435
|
+
function revealAgentExchangeMessage(messageId) {
|
|
1436
|
+
const promptId = agentExchangePromptIdByMessageId.get(String(messageId || '').trim());
|
|
1437
|
+
if (!promptId || agentExchangeFoldChoices.get(promptId) === true) return;
|
|
1438
|
+
agentExchangeFoldChoices.set(promptId, true);
|
|
1439
|
+
syncAgentExchangeFolds();
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1227
1442
|
function createMessageNode(msg, msgId = null, force = false) {
|
|
1228
1443
|
const el = getMessagesElement();
|
|
1229
1444
|
if (!el) return null;
|
|
@@ -1235,6 +1450,10 @@ function createMessageNode(msg, msgId = null, force = false) {
|
|
|
1235
1450
|
seenMessageIds.add(msgId);
|
|
1236
1451
|
}
|
|
1237
1452
|
|
|
1453
|
+
// An event message the relay wrote into a watcher's conversation: a
|
|
1454
|
+
// compact, foldable row, never a bubble of the user's.
|
|
1455
|
+
if (isRelayEventMessage(msg)) return createRelayEventNode(msg, msgId);
|
|
1456
|
+
|
|
1238
1457
|
// A prompt another relay's agent sent carries `origin`: its header line is
|
|
1239
1458
|
// hidden (the badge below says the same), and a stray remote-relay mention
|
|
1240
1459
|
// hint never shows on any user bubble.
|
|
@@ -1256,6 +1475,7 @@ function createMessageNode(msg, msgId = null, force = false) {
|
|
|
1256
1475
|
div.dataset.messageTextFingerprint = fingerprint.text || '';
|
|
1257
1476
|
div.dataset.messageTimestamp = String(msg?.timestamp || '').trim();
|
|
1258
1477
|
if (fingerprint.sourceMessageId) div.dataset.sourceMessageId = fingerprint.sourceMessageId;
|
|
1478
|
+
setAgentExchangeRow(div, msg, msgId);
|
|
1259
1479
|
|
|
1260
1480
|
const label = msg.role === 'user' ? 'You' : '';
|
|
1261
1481
|
const { baseModelId, reasoningEffort } = splitVariantId(msg.model);
|
|
@@ -1359,7 +1579,14 @@ function createMessageNode(msg, msgId = null, force = false) {
|
|
|
1359
1579
|
const mainThoughts = thoughts.filter((t) => !t?.subagentRunId);
|
|
1360
1580
|
const activityHtml = mainActivities.length ? renderActivityMarkup(mainActivities, promotedBoundaryEntry) : '';
|
|
1361
1581
|
const thoughtsHtml = mainThoughts.length ? renderThoughtsMarkup(mainThoughts) : '';
|
|
1362
|
-
|
|
1582
|
+
// One row per sub-agent the turn spawned, as in the composer's task panel;
|
|
1583
|
+
// its title opens the agent's whole record. The store's copy wins: status
|
|
1584
|
+
// events keep it current after the bubble was built.
|
|
1585
|
+
rememberSubagentRuns(subagentRuns);
|
|
1586
|
+
const subagentHtml = subagentRowsMarkup(
|
|
1587
|
+
subagentRuns.map((run) => mergeLiveSubagentRun(run, getSubagentRun(run.subagentRunId))),
|
|
1588
|
+
{ clickable: !IS_SHARED_VIEW },
|
|
1589
|
+
);
|
|
1363
1590
|
// Finished background workflows persisted with this assistant message
|
|
1364
1591
|
// (docs/plans/workflow-progress-tree.md, Phase 4). The template only
|
|
1365
1592
|
// reserves a placeholder; the cards are DOM-built below so digest text
|
|
@@ -1393,8 +1620,13 @@ function createMessageNode(msg, msgId = null, force = false) {
|
|
|
1393
1620
|
const belongsToActiveTurn = !!activeTurnMessageId
|
|
1394
1621
|
&& (activeTurnMessageId === msgId || activeTurnMessageId === sourceMessageId);
|
|
1395
1622
|
const canToggleShareVisibility = !IS_SHARED_VIEW && !!msgId && !isQueuedUserMessage && !belongsToActiveTurn;
|
|
1623
|
+
// From the conversation's pin list, not from the message: the list is
|
|
1624
|
+
// ordered by revision, and a bubble built from a live event has no pin
|
|
1625
|
+
// state of its own.
|
|
1626
|
+
const pinned = isMessagePinned(currentConvId, msgId);
|
|
1396
1627
|
const shareVisibilityActionHtml = canToggleShareVisibility
|
|
1397
1628
|
? `<div class="msg-share-visibility">
|
|
1629
|
+
${buildMessagePinControlsHtml(msgId, pinned)}
|
|
1398
1630
|
${hiddenFromShares ? '<span class="msg-hidden-label">Hidden from shared viewers</span>' : ''}
|
|
1399
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 ? 'Shows this message in shared conversations' : 'Hides this message from shared conversations'}">${hiddenFromShares ? 'Unhide' : 'Hide'}</button>
|
|
1400
1632
|
</div>`
|
|
@@ -1433,6 +1665,8 @@ function createMessageNode(msg, msgId = null, force = false) {
|
|
|
1433
1665
|
<div class="msg-label">${label}${modelTag}${reasoningTag}${modeTag}${autoTag}${continuationTag}${crossProviderTag}${usageTurnTag}${usageRemainingTag}${usageStaleTag} · ${fmtDate(msg.timestamp)}</div>`;
|
|
1434
1666
|
|
|
1435
1667
|
const bubble = div.querySelector('.msg-bubble');
|
|
1668
|
+
// A running sub-agent's row keeps its clock going once the bubble is placed.
|
|
1669
|
+
if (subagentHtml.includes('data-started-at')) setTimeout(syncSubagentRowClock, 0);
|
|
1436
1670
|
rewriteLocalAssetUrlsInNode(bubble, { preferDrive: msg.role === 'assistant' });
|
|
1437
1671
|
linkifyWorkspaceMentionsInNode(bubble);
|
|
1438
1672
|
// One collapsed "Finished background task" card per persisted run, sharing
|
|
@@ -1641,83 +1875,6 @@ export function renderActivityMarkup(activities, promotedBoundaryEntry = undefin
|
|
|
1641
1875
|
return `${progressHtml}${toolsHtml}`;
|
|
1642
1876
|
}
|
|
1643
1877
|
|
|
1644
|
-
// Persisted nested subagent sections: groups a finished message's activities
|
|
1645
|
-
// and thoughts by subagentRunId and renders one collapsible block per run,
|
|
1646
|
-
// nested by parentSubagentId — mirroring the live sub-bubbles.
|
|
1647
|
-
export function renderSubagentRunsMarkup(subagentRuns, activities, thoughts) {
|
|
1648
|
-
const activityByRun = new Map();
|
|
1649
|
-
for (const item of (Array.isArray(activities) ? activities : [])) {
|
|
1650
|
-
const entry = normalizeRelayActivityEntry(item);
|
|
1651
|
-
if (!entry?.subagentRunId) continue;
|
|
1652
|
-
const list = activityByRun.get(entry.subagentRunId) || [];
|
|
1653
|
-
list.push(entry.text);
|
|
1654
|
-
activityByRun.set(entry.subagentRunId, list);
|
|
1655
|
-
}
|
|
1656
|
-
const thoughtsByRun = new Map();
|
|
1657
|
-
for (const thought of (Array.isArray(thoughts) ? thoughts : [])) {
|
|
1658
|
-
const runId = thought?.subagentRunId ? String(thought.subagentRunId).trim() : '';
|
|
1659
|
-
const text = String(thought?.text || '').trim();
|
|
1660
|
-
if (!runId || !text) continue;
|
|
1661
|
-
const list = thoughtsByRun.get(runId) || [];
|
|
1662
|
-
list.push({ reasoningId: String(thought?.reasoningId || '').trim(), text });
|
|
1663
|
-
thoughtsByRun.set(runId, list);
|
|
1664
|
-
}
|
|
1665
|
-
const runsById = new Map();
|
|
1666
|
-
for (const run of (Array.isArray(subagentRuns) ? subagentRuns : [])) {
|
|
1667
|
-
const runId = String(run?.subagentRunId || '').trim();
|
|
1668
|
-
if (!runId) continue;
|
|
1669
|
-
runsById.set(runId, {
|
|
1670
|
-
subagentRunId: runId,
|
|
1671
|
-
parentSubagentId: run?.parentSubagentId ? String(run.parentSubagentId).trim() : null,
|
|
1672
|
-
displayName: String(run?.displayName || '').trim() || `Subagent ${runId.slice(0, 8)}`,
|
|
1673
|
-
status: String(run?.status || 'completed').trim().toLowerCase(),
|
|
1674
|
-
});
|
|
1675
|
-
}
|
|
1676
|
-
// Items may reference runs whose metadata rows are unavailable; synthesize.
|
|
1677
|
-
for (const runId of new Set([...activityByRun.keys(), ...thoughtsByRun.keys()])) {
|
|
1678
|
-
if (!runsById.has(runId)) {
|
|
1679
|
-
runsById.set(runId, {
|
|
1680
|
-
subagentRunId: runId,
|
|
1681
|
-
parentSubagentId: null,
|
|
1682
|
-
displayName: `Subagent ${runId.slice(0, 8)}`,
|
|
1683
|
-
status: 'completed',
|
|
1684
|
-
});
|
|
1685
|
-
}
|
|
1686
|
-
}
|
|
1687
|
-
if (!runsById.size) return '';
|
|
1688
|
-
const childrenByParent = new Map();
|
|
1689
|
-
const roots = [];
|
|
1690
|
-
for (const run of runsById.values()) {
|
|
1691
|
-
const parentId = run.parentSubagentId && runsById.has(run.parentSubagentId) ? run.parentSubagentId : null;
|
|
1692
|
-
if (parentId) {
|
|
1693
|
-
const children = childrenByParent.get(parentId) || [];
|
|
1694
|
-
children.push(run);
|
|
1695
|
-
childrenByParent.set(parentId, children);
|
|
1696
|
-
} else {
|
|
1697
|
-
roots.push(run);
|
|
1698
|
-
}
|
|
1699
|
-
}
|
|
1700
|
-
const renderRun = (run) => {
|
|
1701
|
-
const runActivities = activityByRun.get(run.subagentRunId) || [];
|
|
1702
|
-
const runThoughts = thoughtsByRun.get(run.subagentRunId) || [];
|
|
1703
|
-
const children = childrenByParent.get(run.subagentRunId) || [];
|
|
1704
|
-
const thoughtsBlock = runThoughts.length
|
|
1705
|
-
? `<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>`
|
|
1706
|
-
: '';
|
|
1707
|
-
const activitiesBlock = runActivities.length
|
|
1708
|
-
? `<div class="msg-activity-list">${runActivities.map((text) => `<div class="msg-activity-item">${escHtml(decorateActivityText(text))}</div>`).join('')}</div>`
|
|
1709
|
-
: '';
|
|
1710
|
-
const childrenBlock = children.map(renderRun).join('');
|
|
1711
|
-
const statusLabel = run.status && run.status !== 'completed' ? ` · ${escHtml(run.status)}` : '';
|
|
1712
|
-
return `
|
|
1713
|
-
<details class="msg-activity msg-subagent-run" data-subagent-run-id="${escHtml(run.subagentRunId)}">
|
|
1714
|
-
<summary>🤖 ${escHtml(run.displayName)}${statusLabel}${runActivities.length ? ` (${runActivities.length})` : ''}</summary>
|
|
1715
|
-
${thoughtsBlock}${activitiesBlock}${childrenBlock}
|
|
1716
|
-
</details>`;
|
|
1717
|
-
};
|
|
1718
|
-
return roots.map(renderRun).join('');
|
|
1719
|
-
}
|
|
1720
|
-
|
|
1721
1878
|
// Places the live bubble directly under its owning user message, or leaves a
|
|
1722
1879
|
// bubble already in the transcript where it is when that row is not rendered.
|
|
1723
1880
|
// Idempotent so the reuse path can call it every tick: a bubble built before
|
|
@@ -1738,6 +1895,9 @@ function anchorThinkingBubble(div, messageId) {
|
|
|
1738
1895
|
if (next) el.insertBefore(div, next);
|
|
1739
1896
|
else el.appendChild(div);
|
|
1740
1897
|
if (div.dataset.compactBoundary) syncSeparatorsNow();
|
|
1898
|
+
// Under a folded agent prompt the bubble folds with it, and the row says
|
|
1899
|
+
// the turn is working.
|
|
1900
|
+
else syncAgentExchangeFoldsNow();
|
|
1741
1901
|
} else if (div.parentNode !== el) {
|
|
1742
1902
|
el.appendChild(div);
|
|
1743
1903
|
}
|
|
@@ -1813,6 +1973,7 @@ export function showThinking(messageId = null, autoScroll = true) {
|
|
|
1813
1973
|
export function removeThinking() {
|
|
1814
1974
|
thinkingMessageId = null;
|
|
1815
1975
|
lastInFlightSnapshotKey = '';
|
|
1976
|
+
const hadBubble = !!document.getElementById('thinking-indicator');
|
|
1816
1977
|
const wasCompacting = !!document.getElementById('thinking-indicator')?.dataset?.compactBoundary;
|
|
1817
1978
|
unobserveThinkingBubble();
|
|
1818
1979
|
document.getElementById('thinking-indicator')?.remove();
|
|
@@ -1820,6 +1981,8 @@ export function removeThinking() {
|
|
|
1820
1981
|
// caller's same-tick work: a reply appended right after this call takes
|
|
1821
1982
|
// the line over (relabelled in place) instead of it being dropped first.
|
|
1822
1983
|
if (wasCompacting) queueMicrotask(syncSeparatorsNow);
|
|
1984
|
+
// A turn that ended without a reply (cancelled) stops saying "working".
|
|
1985
|
+
if (hadBubble) syncAgentExchangeFoldsNow();
|
|
1823
1986
|
}
|
|
1824
1987
|
|
|
1825
1988
|
// The live turn's compaction line: the bubble stamps the turn's compaction
|
|
@@ -2038,6 +2201,9 @@ export function restoreInFlightThinking(inFlight, autoScroll = true) {
|
|
|
2038
2201
|
return;
|
|
2039
2202
|
}
|
|
2040
2203
|
lastInFlightSnapshotKey = snapshotKey;
|
|
2204
|
+
// The snapshot may have been read before the newest live frame was stored.
|
|
2205
|
+
const liveStreamSeq = relayStreamStateByMessageId.get(messageId)?.seq || 0;
|
|
2206
|
+
const liveStreamText = relayStreamTextByMessageId.get(messageId);
|
|
2041
2207
|
clearRelayStreamState();
|
|
2042
2208
|
setConversationTurnState(currentConvId, { messageId, status: 'processing' });
|
|
2043
2209
|
const activities = mergeRelayActivityTexts(
|
|
@@ -2079,14 +2245,19 @@ export function restoreInFlightThinking(inFlight, autoScroll = true) {
|
|
|
2079
2245
|
// Seed the per-thread stream text before the bubbles are built so both the
|
|
2080
2246
|
// reply preview and each subagent bubble repaint on reload.
|
|
2081
2247
|
const streamTextByThread = deriveInFlightStreamTextByThread(inFlight);
|
|
2082
|
-
if (streamTextByThread.main) {
|
|
2248
|
+
if (streamTextByThread.main && streamTextByThread.main.seq >= liveStreamSeq) {
|
|
2083
2249
|
relayStreamTextByMessageId.set(messageId, streamTextByThread.main.text);
|
|
2250
|
+
} else if (liveStreamText !== undefined) {
|
|
2251
|
+
relayStreamTextByMessageId.set(messageId, liveStreamText);
|
|
2084
2252
|
}
|
|
2085
2253
|
for (const [runId, entry] of streamTextByThread.bySubagentRunId) {
|
|
2086
2254
|
upsertSubagentRun({ subagentRunId: runId, messageId, conversationId: currentConvId });
|
|
2087
2255
|
setSubagentStreamText(runId, entry.text);
|
|
2088
2256
|
}
|
|
2089
2257
|
showThinking(messageId, autoScroll);
|
|
2258
|
+
// relay_stream frames are volatile, so a dropped one reaches the bubble only
|
|
2259
|
+
// from here; showThinking does not repaint a bubble it reuses.
|
|
2260
|
+
renderThinkingStream();
|
|
2090
2261
|
renderThinkingActivities();
|
|
2091
2262
|
renderThinkingThoughts();
|
|
2092
2263
|
renderRestoredSubagentBubbles(messageId);
|
|
@@ -2571,6 +2742,8 @@ export function applyRelayStreamEvent({
|
|
|
2571
2742
|
if (id !== thinkingMessageId) return true;
|
|
2572
2743
|
if (runId) renderSubagentStream(runId, text);
|
|
2573
2744
|
else renderThinkingStream();
|
|
2745
|
+
// Again once the text is in: it grew below the end the status scrolled to.
|
|
2746
|
+
if (autoScroll && !getThinkingFollowMode()) scrollBottom();
|
|
2574
2747
|
return true;
|
|
2575
2748
|
}
|
|
2576
2749
|
|
|
@@ -2898,6 +3071,8 @@ export function replaceRenderedMessage(msg, msgId) {
|
|
|
2898
3071
|
seenMessageIds.delete(id);
|
|
2899
3072
|
const fresh = createMessageNode(msg, id, true);
|
|
2900
3073
|
placeholder.replaceWith(fresh || existing);
|
|
3074
|
+
// The rebuilt row takes its merge and fold classes back.
|
|
3075
|
+
if (fresh) syncSeparatorsNow();
|
|
2901
3076
|
return Boolean(fresh);
|
|
2902
3077
|
}
|
|
2903
3078
|
|
|
@@ -3134,6 +3309,38 @@ async function cancelSubagentByRunId(conversationId, subagentRunId) {
|
|
|
3134
3309
|
updateSubagentStopButton(targetSubagentRunId, false);
|
|
3135
3310
|
}
|
|
3136
3311
|
|
|
3312
|
+
const PINNED_LABEL_HTML = '<span class="msg-pinned-label">📍 Pinned</span>';
|
|
3313
|
+
|
|
3314
|
+
function pinButtonTitle(pinned) {
|
|
3315
|
+
return pinned ? 'Removes this message from the pinned list' : 'Adds this message to the pinned list';
|
|
3316
|
+
}
|
|
3317
|
+
|
|
3318
|
+
// The pin half of a bubble's corner row. No whitespace between label and
|
|
3319
|
+
// button: syncRenderedPinState finds the label as the button's sibling.
|
|
3320
|
+
function buildMessagePinControlsHtml(msgId, pinned) {
|
|
3321
|
+
return `${pinned ? PINNED_LABEL_HTML : ''}<button type="button" class="msg-pin-btn" data-action="toggle-pin" data-message-id="${escHtml(msgId)}" data-pinned="${pinned ? 'true' : 'false'}" title="${pinButtonTitle(pinned)}">${pinned ? 'Unpin' : 'Pin'}</button>`;
|
|
3322
|
+
}
|
|
3323
|
+
|
|
3324
|
+
// The pin list changed (this page, another device): bring the bubbles on the
|
|
3325
|
+
// page in line without rebuilding them.
|
|
3326
|
+
export function syncRenderedPinState(pinnedIds) {
|
|
3327
|
+
const el = getMessagesElement();
|
|
3328
|
+
if (!el) return;
|
|
3329
|
+
const ids = pinnedIds instanceof Set ? pinnedIds : new Set(pinnedIds || []);
|
|
3330
|
+
for (const button of el.querySelectorAll('.msg-pin-btn')) {
|
|
3331
|
+
const pinned = ids.has(String(button.dataset.messageId || '').trim());
|
|
3332
|
+
if ((button.dataset.pinned === 'true') === pinned) continue;
|
|
3333
|
+
button.dataset.pinned = pinned ? 'true' : 'false';
|
|
3334
|
+
button.disabled = false;
|
|
3335
|
+
button.textContent = pinned ? 'Unpin' : 'Pin';
|
|
3336
|
+
button.title = pinButtonTitle(pinned);
|
|
3337
|
+
const sibling = button.previousElementSibling;
|
|
3338
|
+
const label = sibling?.classList?.contains('msg-pinned-label') ? sibling : null;
|
|
3339
|
+
if (pinned && !label) button.insertAdjacentHTML('beforebegin', PINNED_LABEL_HTML);
|
|
3340
|
+
if (!pinned && label) label.remove();
|
|
3341
|
+
}
|
|
3342
|
+
}
|
|
3343
|
+
|
|
3137
3344
|
async function toggleMessageShareVisibility(conversationId, messageId, hiddenFromShares) {
|
|
3138
3345
|
const conversationKey = String(conversationId || '').trim();
|
|
3139
3346
|
const targetMessageId = String(messageId || '').trim();
|
|
@@ -3192,7 +3399,7 @@ function runRelayErrorCta(cta) {
|
|
|
3192
3399
|
}
|
|
3193
3400
|
|
|
3194
3401
|
function handleBubbleActionClick(event) {
|
|
3195
|
-
const btn = event.target.closest('.bubble-action-btn, .msg-share-visibility-btn, .thinking-follow-btn, .subagent-bubble-header');
|
|
3402
|
+
const btn = event.target.closest('.bubble-action-btn, .msg-share-visibility-btn, .msg-pin-btn, .thinking-follow-btn, .subagent-bubble-header');
|
|
3196
3403
|
if (!btn) return;
|
|
3197
3404
|
const action = btn.dataset.action;
|
|
3198
3405
|
const messageId = btn.dataset.messageId;
|
|
@@ -3214,6 +3421,12 @@ function handleBubbleActionClick(event) {
|
|
|
3214
3421
|
);
|
|
3215
3422
|
}
|
|
3216
3423
|
|
|
3424
|
+
if (action === 'toggle-pin' && messageId) {
|
|
3425
|
+
event.preventDefault();
|
|
3426
|
+
event.stopPropagation();
|
|
3427
|
+
void toggleMessagePin(currentConvId, messageId, btn.dataset.pinned === 'true');
|
|
3428
|
+
}
|
|
3429
|
+
|
|
3217
3430
|
if (action === 'stop-turn' && messageId) {
|
|
3218
3431
|
event.preventDefault();
|
|
3219
3432
|
event.stopPropagation();
|
|
@@ -3269,30 +3482,48 @@ function handleBubbleActionKeydown(event) {
|
|
|
3269
3482
|
toggleSubagentFold(header.dataset.subagentRunId);
|
|
3270
3483
|
}
|
|
3271
3484
|
|
|
3272
|
-
//
|
|
3273
|
-
//
|
|
3274
|
-
//
|
|
3275
|
-
// native controls untouched.
|
|
3276
|
-
function
|
|
3277
|
-
const
|
|
3278
|
-
|
|
3279
|
-
if (
|
|
3280
|
-
|
|
3281
|
-
|
|
3485
|
+
// A tap on a media attachment (its picture, chip or name) or on a picture
|
|
3486
|
+
// embedded in a bubble opens it in the file viewer, together with the other
|
|
3487
|
+
// media of the conversation to swipe through. Inline video/audio players in
|
|
3488
|
+
// bubbles keep their native controls untouched.
|
|
3489
|
+
function handleChatMediaClick(event) {
|
|
3490
|
+
const attachment = event.target.closest?.('.msg-attachment[data-media-url]');
|
|
3491
|
+
let node = null;
|
|
3492
|
+
if (attachment) {
|
|
3493
|
+
// The buttons under a generated image ("Edit this image") do their own work.
|
|
3494
|
+
if (event.target.closest?.('button')) return;
|
|
3495
|
+
if (!event.target.closest?.('img, .msg-attachment-video-chip, [data-media-open]')) return;
|
|
3496
|
+
node = attachment;
|
|
3497
|
+
} else {
|
|
3498
|
+
const img = event.target.closest?.('.msg-bubble img');
|
|
3499
|
+
if (!img || img.closest('.msg-attachment')) return;
|
|
3500
|
+
if (IS_SHARED_VIEW) return; // the file routes are cookie-authed; shared viewers would only see a failed load
|
|
3501
|
+
if (!String(img.getAttribute('src') || '').trim()) return;
|
|
3502
|
+
node = img;
|
|
3503
|
+
}
|
|
3282
3504
|
event.preventDefault();
|
|
3283
3505
|
event.stopPropagation();
|
|
3284
|
-
const
|
|
3285
|
-
|
|
3286
|
-
|
|
3287
|
-
|
|
3288
|
-
|
|
3289
|
-
|
|
3290
|
-
|
|
3291
|
-
|
|
3506
|
+
const options = { includeEmbedded: !IS_SHARED_VIEW, parseAppFileHref };
|
|
3507
|
+
const gallery = chatMediaGallery(getMessagesElement(), node, options);
|
|
3508
|
+
const item = gallery ? gallery.items[gallery.index] : chatMediaItemFromNode(node, options);
|
|
3509
|
+
void openGalleryItem(item, gallery);
|
|
3510
|
+
}
|
|
3511
|
+
|
|
3512
|
+
// On a touch screen the corner row (Pin, Hide) is revealed by a tap on the
|
|
3513
|
+
// bubble. The tap leaves an emulated :hover behind, which the CSS also honours,
|
|
3514
|
+
// but Chromium drops that hover at the next scroll or layout change on some
|
|
3515
|
+
// platforms (seen on Linux), and the row went back to `pointer-events: none`
|
|
3516
|
+
// before it could be tapped. The class keeps the reveal until a tap lands
|
|
3517
|
+
// outside the bubble. A pointer with hover keeps the hover reveal alone.
|
|
3518
|
+
const REVEALED_BUBBLE_CLASS = 'msg-actions-revealed';
|
|
3519
|
+
function handleBubbleTapReveal(event) {
|
|
3520
|
+
const bubble = event.target?.closest?.('.msg-bubble') || null;
|
|
3521
|
+
for (const revealed of document.querySelectorAll(`.msg-bubble.${REVEALED_BUBBLE_CLASS}`)) {
|
|
3522
|
+
if (revealed !== bubble) revealed.classList.remove(REVEALED_BUBBLE_CLASS);
|
|
3292
3523
|
}
|
|
3293
|
-
|
|
3294
|
-
|
|
3295
|
-
|
|
3524
|
+
if (!bubble || IS_SHARED_VIEW) return;
|
|
3525
|
+
if (!window.matchMedia?.('(hover: none)')?.matches) return;
|
|
3526
|
+
bubble.classList.add(REVEALED_BUBBLE_CLASS);
|
|
3296
3527
|
}
|
|
3297
3528
|
|
|
3298
3529
|
export function initBubbleActionHandlers() {
|
|
@@ -3300,7 +3531,9 @@ export function initBubbleActionHandlers() {
|
|
|
3300
3531
|
if (!messagesEl) return;
|
|
3301
3532
|
messagesEl.addEventListener('click', handleBubbleActionClick);
|
|
3302
3533
|
messagesEl.addEventListener('keydown', handleBubbleActionKeydown);
|
|
3303
|
-
messagesEl.addEventListener('click',
|
|
3534
|
+
messagesEl.addEventListener('click', handleChatMediaClick);
|
|
3535
|
+
// On the document: a tap outside the transcript clears the reveal too.
|
|
3536
|
+
document.addEventListener('click', handleBubbleTapReveal);
|
|
3304
3537
|
initThinkingFollow({ getCurrentConversationId: () => currentConvId });
|
|
3305
3538
|
}
|
|
3306
3539
|
|
|
@@ -3311,6 +3544,14 @@ export function appendMessage(msg, scroll = true, msgId = null, force = false, i
|
|
|
3311
3544
|
if (empty) empty.remove();
|
|
3312
3545
|
const node = createMessageNode(msg, msgId, force);
|
|
3313
3546
|
const isNewNode = !!node && !node.parentNode;
|
|
3547
|
+
// 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 belongs to its
|
|
3549
|
+
// agent exchange too.
|
|
3550
|
+
const exchangeRow = isNewNode ? node[AGENT_EXCHANGE_ROW_KEY] : null;
|
|
3551
|
+
const anchorId = String(insertAfterId || '').trim();
|
|
3552
|
+
if (exchangeRow?.role === 'assistant' && !exchangeRow.sourceMessageId && anchorId && anchorId !== String(msgId || '').trim()) {
|
|
3553
|
+
exchangeRow.sourceMessageId = anchorId;
|
|
3554
|
+
}
|
|
3314
3555
|
// Insert without scrolling: the separator pass below can add a row above
|
|
3315
3556
|
// this message, and scrolling before it runs leaves the viewport a
|
|
3316
3557
|
// separator-height short of the bottom (which then reads as "not at
|
|
@@ -3412,6 +3653,105 @@ function buildMessageSnapshotKey(messages = [], meta = {}) {
|
|
|
3412
3653
|
});
|
|
3413
3654
|
}
|
|
3414
3655
|
|
|
3656
|
+
// Counts jumps into the history (a search result, a pinned message). A reload
|
|
3657
|
+
// of the END of the conversation that was asked for before a jump and is
|
|
3658
|
+
// answered after it would put the end back over the window just jumped to.
|
|
3659
|
+
// Only jumps count: an ordinary reload must still be able to correct another.
|
|
3660
|
+
let transcriptJumpEpoch = 0;
|
|
3661
|
+
|
|
3662
|
+
export function getTranscriptJumpEpoch() {
|
|
3663
|
+
return transcriptJumpEpoch;
|
|
3664
|
+
}
|
|
3665
|
+
|
|
3666
|
+
export function noteTranscriptJump() {
|
|
3667
|
+
transcriptJumpEpoch += 1;
|
|
3668
|
+
}
|
|
3669
|
+
|
|
3670
|
+
// Counts rows the socket put on the page: another client's message, a reply,
|
|
3671
|
+
// a row rewritten in place. A reload whose request was on its way when one
|
|
3672
|
+
// landed may not have it, and its rebuild would take it off the page again
|
|
3673
|
+
// (transcript-reload-freshness.mjs).
|
|
3674
|
+
let transcriptLiveRowEpoch = 0;
|
|
3675
|
+
|
|
3676
|
+
export function getTranscriptLiveRowEpoch() {
|
|
3677
|
+
return transcriptLiveRowEpoch;
|
|
3678
|
+
}
|
|
3679
|
+
|
|
3680
|
+
export function noteTranscriptLiveRow() {
|
|
3681
|
+
transcriptLiveRowEpoch += 1;
|
|
3682
|
+
}
|
|
3683
|
+
|
|
3684
|
+
function liveConversationCount() {
|
|
3685
|
+
return Object.values(conversations || {}).filter((conversation) => conversation && conversation.archived !== true).length;
|
|
3686
|
+
}
|
|
3687
|
+
|
|
3688
|
+
function runEmptyStateAction(action) {
|
|
3689
|
+
switch (String(action || '').trim()) {
|
|
3690
|
+
case 'open-settings':
|
|
3691
|
+
openSettingsModal('providers');
|
|
3692
|
+
return;
|
|
3693
|
+
case 'new-chat':
|
|
3694
|
+
void window.newConversation?.();
|
|
3695
|
+
return;
|
|
3696
|
+
case 'show-conversations':
|
|
3697
|
+
toggleSidebar(true);
|
|
3698
|
+
return;
|
|
3699
|
+
default:
|
|
3700
|
+
}
|
|
3701
|
+
}
|
|
3702
|
+
|
|
3703
|
+
function buildEmptyStateNode(model) {
|
|
3704
|
+
const node = document.createElement('div');
|
|
3705
|
+
node.className = 'empty-state';
|
|
3706
|
+
node.dataset.variant = model.variant;
|
|
3707
|
+
const actions = model.actions.map((action) => {
|
|
3708
|
+
const classes = ['empty-state-btn'];
|
|
3709
|
+
if (action.primary) classes.push('empty-state-btn-accent');
|
|
3710
|
+
if (action.link) classes.push('empty-state-link');
|
|
3711
|
+
return `<button type="button" id="${escHtml(action.id)}" class="${classes.join(' ')}" data-empty-action="${escHtml(action.action)}">${escHtml(action.label)}</button>`;
|
|
3712
|
+
}).join('');
|
|
3713
|
+
node.innerHTML = `
|
|
3714
|
+
<div class="icon">${escHtml(model.icon)}</div>
|
|
3715
|
+
<h3>${escHtml(model.heading)}</h3>
|
|
3716
|
+
<p>${escHtml(model.text)}</p>
|
|
3717
|
+
<div class="empty-state-actions">${actions}</div>`;
|
|
3718
|
+
for (const button of node.querySelectorAll('[data-empty-action]')) {
|
|
3719
|
+
button.addEventListener('click', (event) => {
|
|
3720
|
+
event.preventDefault();
|
|
3721
|
+
runEmptyStateAction(button.dataset.emptyAction);
|
|
3722
|
+
});
|
|
3723
|
+
}
|
|
3724
|
+
return node;
|
|
3725
|
+
}
|
|
3726
|
+
|
|
3727
|
+
/**
|
|
3728
|
+
* Draws the "no conversation open" screen into the transcript, or redraws it
|
|
3729
|
+
* when readiness or the conversation list changed. A no-op with a
|
|
3730
|
+
* conversation open, in the shared view, or while the status view owns the
|
|
3731
|
+
* transcript. Idempotent: the same model leaves the node alone.
|
|
3732
|
+
*/
|
|
3733
|
+
export function renderNoConversationState() {
|
|
3734
|
+
if (IS_SHARED_VIEW) return false;
|
|
3735
|
+
if (String(currentConvId || '').trim()) return false;
|
|
3736
|
+
const el = getMessagesElement();
|
|
3737
|
+
if (!el || el.querySelector('.status-view')) return false;
|
|
3738
|
+
const existing = el.querySelector('.empty-state');
|
|
3739
|
+
// A transcript with rows in it belongs to something else (a render in flight).
|
|
3740
|
+
if (!existing && el.children.length) return false;
|
|
3741
|
+
const model = resolveNoConversationState({
|
|
3742
|
+
readiness: getProviderReadiness(),
|
|
3743
|
+
conversationCount: liveConversationCount(),
|
|
3744
|
+
mobile: isMobileComposerViewport(),
|
|
3745
|
+
});
|
|
3746
|
+
const key = JSON.stringify(model);
|
|
3747
|
+
if (existing && existing.dataset.emptyStateKey === key) return true;
|
|
3748
|
+
const node = buildEmptyStateNode(model);
|
|
3749
|
+
node.dataset.emptyStateKey = key;
|
|
3750
|
+
if (existing) existing.replaceWith(node);
|
|
3751
|
+
else el.appendChild(node);
|
|
3752
|
+
return true;
|
|
3753
|
+
}
|
|
3754
|
+
|
|
3415
3755
|
export function renderMessages(msgs, scroll = true, meta = {}) {
|
|
3416
3756
|
const el = getMessagesElement();
|
|
3417
3757
|
if (!el) return false;
|
|
@@ -3492,11 +3832,17 @@ export function renderMessages(msgs, scroll = true, meta = {}) {
|
|
|
3492
3832
|
&& thinkingNode.dataset.conversationId === renderConversationKey) ? thinkingNode : null;
|
|
3493
3833
|
if (thinkingNodeInTranscript && !preservedThinkingNode) removeThinking();
|
|
3494
3834
|
if (!ordered.length && !preservedPendingNodes.length && !preservedThinkingNode) {
|
|
3495
|
-
|
|
3496
|
-
|
|
3497
|
-
|
|
3498
|
-
|
|
3499
|
-
|
|
3835
|
+
if (!currentConvId && !IS_SHARED_VIEW) {
|
|
3836
|
+
// Nothing open: the screen says what can be done here (no-conversation-state.mjs).
|
|
3837
|
+
el.innerHTML = '';
|
|
3838
|
+
renderNoConversationState();
|
|
3839
|
+
} else {
|
|
3840
|
+
el.innerHTML = `<div class="empty-state">
|
|
3841
|
+
<div class="icon">${currentConvId ? '💬' : '🚀'}</div>
|
|
3842
|
+
<h3>${currentConvId ? 'No messages yet' : 'New Conversation'}</h3>
|
|
3843
|
+
<p>${currentConvId ? 'Start the conversation below' : 'Type your first message below'}</p>
|
|
3844
|
+
</div>`;
|
|
3845
|
+
}
|
|
3500
3846
|
resetConversationHistoryState();
|
|
3501
3847
|
renderRelayQuestions();
|
|
3502
3848
|
renderRelayBoards();
|
|
@@ -3590,12 +3936,20 @@ export async function loadNewerConversationMessages() {
|
|
|
3590
3936
|
await conversationFutureLoader.loadMore();
|
|
3591
3937
|
}
|
|
3592
3938
|
|
|
3939
|
+
// False while a window in the middle of the history is on the page (a search
|
|
3940
|
+
// result or a pinned message was jumped to) and newer messages are unloaded.
|
|
3941
|
+
export function isConversationWindowAtTail() {
|
|
3942
|
+
return !conversationHistoryState.hasMoreNewer;
|
|
3943
|
+
}
|
|
3944
|
+
|
|
3593
3945
|
export function focusConversationMessageById(messageId, { behavior = 'smooth', block = 'center' } = {}) {
|
|
3594
3946
|
const id = String(messageId || '').trim();
|
|
3595
3947
|
if (!id) return false;
|
|
3596
3948
|
const el = getMessagesElement();
|
|
3597
3949
|
if (!el) return false;
|
|
3598
|
-
|
|
3950
|
+
// A message in a folded agent exchange is shown before it is scrolled to.
|
|
3951
|
+
revealAgentExchangeMessage(id);
|
|
3952
|
+
const target = el.querySelector(`.msg[data-message-id="${CSS.escape(id)}"]`);
|
|
3599
3953
|
if (!target) return false;
|
|
3600
3954
|
target.scrollIntoView({ behavior, block, inline: 'nearest' });
|
|
3601
3955
|
target.classList.add('msg-search-target');
|
|
@@ -3648,6 +4002,13 @@ export async function sendMessage() {
|
|
|
3648
4002
|
return;
|
|
3649
4003
|
}
|
|
3650
4004
|
if (!hasDraft) return;
|
|
4005
|
+
// No conversation open and no provider ready: a send would create a
|
|
4006
|
+
// Copilot chat that cannot run. The composer is locked in this state; this
|
|
4007
|
+
// guard covers a programmatic send, before any upload starts.
|
|
4008
|
+
if (!String(currentConvId || '').trim() && isProviderSetupRequired()) {
|
|
4009
|
+
showTransientRelayNotice('Set up a provider in Settings → Providers before starting a chat.', 6000);
|
|
4010
|
+
return;
|
|
4011
|
+
}
|
|
3651
4012
|
closeSlashAutocomplete();
|
|
3652
4013
|
closeMentionAutocomplete();
|
|
3653
4014
|
|