@oar-sh/oar 0.9.9 → 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 +247 -0
- package/README.md +78 -9
- package/bin/bat/oar.bat +46 -46
- package/package.json +2 -2
- package/server/README.md +286 -3
- 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 +36 -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 +61 -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 +516 -6
- package/server/public/app/background-tasks-view.mjs +18 -26
- package/server/public/app/bootstrap.js +186 -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 +361 -85
- package/server/public/app/explorer-edit-state.mjs +68 -0
- package/server/public/app/host-suspend-ui.js +6 -3
- package/server/public/app/journal-view.js +146 -26
- package/server/public/app/no-conversation-state.mjs +58 -0
- package/server/public/app/provider-readiness.mjs +120 -0
- package/server/public/app/relay-board-view.js +3 -0
- 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/settings-modal.js +53 -1
- package/server/public/app/socket-handlers.js +59 -28
- package/server/public/app/store.js +19 -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-history.mjs +5 -3
- package/server/public/index.html +396 -5
- package/server/repositories/message-repository.mjs +29 -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 +515 -50
- package/server/routes/relay-event-routes.mjs +157 -0
- package/server/routes/remote-relay-routes.mjs +15 -0
- package/server/routes/sessions-routes.mjs +72 -0
- package/server/server-runtime.mjs +245 -62
- package/server/services/explorer-entry-name.mjs +26 -0
- package/server/services/explorer-mutations.mjs +122 -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/session-activity-tracker.mjs +487 -0
- package/server/services/session-history-refresh-service.mjs +7 -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,6 +47,10 @@ 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';
|
|
@@ -55,12 +59,25 @@ import { linkifyWorkspaceMentionsInNode, parseAppFileHref, renderMarkdownPreview
|
|
|
55
59
|
import { renderAttachmentMarkup, removeComposerAttachments, uploadAttachments, setComposerAttachments, setRepoBrowserSessionInfo, openGalleryItem } from './attachments-view.js';
|
|
56
60
|
import { chatMediaGallery, chatMediaItemFromNode } from './viewer-chat-gallery.mjs';
|
|
57
61
|
import { buildWorkflowRunCard } from './background-tasks-view.mjs';
|
|
62
|
+
import { mergeLiveSubagentRun, subagentRowsMarkup } from './subagent-row.mjs';
|
|
63
|
+
import { rememberSubagentRuns, syncSubagentRowClock } from './subagent-viewer.mjs';
|
|
58
64
|
import { isMessagePinned, toggleMessagePin } from './pinned-messages-view.mjs';
|
|
59
65
|
import { parsePreviewCommand, runPreviewCommand } from './preview-command.mjs';
|
|
60
66
|
import { buildTranscriptPreviewCard } from './preview-cards.mjs';
|
|
61
67
|
import { closeSlashAutocomplete, handleSlashAutocompleteKey, updateSlashAutocomplete } from './slash-autocomplete.mjs';
|
|
62
68
|
import { closeMentionAutocomplete, handleMentionAutocompleteKey, updateMentionAutocomplete } from './mention-autocomplete.mjs';
|
|
63
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';
|
|
64
81
|
import { stripRemoteRelayDisplayArtifacts } from './relay-prompt-sanitizer.mjs';
|
|
65
82
|
import { evaluateUnknownCommandGuard } from './slash-commands.mjs';
|
|
66
83
|
import { attachCodeCopyButtons } from './code-copy.mjs';
|
|
@@ -98,9 +115,11 @@ import {
|
|
|
98
115
|
syncSeparatorRail,
|
|
99
116
|
syncTranscriptSeparators,
|
|
100
117
|
} from './transcript-separators.mjs';
|
|
101
|
-
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';
|
|
102
119
|
import { resolveSubagentFold, subagentFoldSummary, toggledSubagentFoldChoice } from './subagent-fold.mjs';
|
|
103
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';
|
|
104
123
|
import { buildLiveMessageFingerprint } from './live-message-dedupe.mjs';
|
|
105
124
|
import { createInfiniteLoader } from './infinite-loader.js';
|
|
106
125
|
import { normalizeDraftTimestampMs, isIncomingDraftTimestampStale } from './conversation-draft-timestamp-utils.mjs';
|
|
@@ -150,6 +169,8 @@ function syncSeparatorsNow() {
|
|
|
150
169
|
if (!el) return;
|
|
151
170
|
syncTranscriptSeparators(el);
|
|
152
171
|
syncSteeredTurnMerge(el);
|
|
172
|
+
// After the merge pass: a steered row's class is one of its inputs.
|
|
173
|
+
syncAgentExchangeFolds(el);
|
|
153
174
|
observeTranscriptResize(el);
|
|
154
175
|
}
|
|
155
176
|
|
|
@@ -265,6 +286,7 @@ export function jumpToImageParent(messageId) {
|
|
|
265
286
|
showTransientRelayNotice('Load earlier messages to view the source image.');
|
|
266
287
|
return;
|
|
267
288
|
}
|
|
289
|
+
revealAgentExchangeMessage(id);
|
|
268
290
|
node.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
269
291
|
node.classList.add('message-highlight');
|
|
270
292
|
window.setTimeout(() => node.classList.remove('message-highlight'), 1400);
|
|
@@ -355,6 +377,8 @@ const conversationHistoryLoader = createInfiniteLoader({
|
|
|
355
377
|
// A steering pair split across the page boundary heals here (the absorbed
|
|
356
378
|
// reply just arrived above its already-rendered steered user row).
|
|
357
379
|
syncSteeredTurnMerge(el);
|
|
380
|
+
// The page's agent exchanges fold before the height delta is measured.
|
|
381
|
+
syncAgentExchangeFolds(el);
|
|
358
382
|
requestAnimationFrame(() => {
|
|
359
383
|
if (!el || String(currentConvId || '').trim() !== currentId) return;
|
|
360
384
|
const nextScrollHeight = el.scrollHeight;
|
|
@@ -555,11 +579,28 @@ function syncSendButtonState() {
|
|
|
555
579
|
steeringHeld: steeringHold.held,
|
|
556
580
|
steeringHoldReason: steeringHold.reason,
|
|
557
581
|
archived: conversations[String(currentConvId || '').trim()]?.archived === true,
|
|
582
|
+
setupRequired: !String(currentConvId || '').trim() && isProviderSetupRequired(),
|
|
558
583
|
});
|
|
559
584
|
btn.disabled = state.disabled;
|
|
560
585
|
btn.dataset.action = state.action;
|
|
561
586
|
btn.textContent = state.label;
|
|
562
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
|
+
}
|
|
563
604
|
}
|
|
564
605
|
|
|
565
606
|
// Button-only refresh for status, poll and question paths. It must never save
|
|
@@ -1226,6 +1267,178 @@ function splitVariantId(modelVariantId = '') {
|
|
|
1226
1267
|
};
|
|
1227
1268
|
}
|
|
1228
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
|
+
|
|
1229
1442
|
function createMessageNode(msg, msgId = null, force = false) {
|
|
1230
1443
|
const el = getMessagesElement();
|
|
1231
1444
|
if (!el) return null;
|
|
@@ -1237,6 +1450,10 @@ function createMessageNode(msg, msgId = null, force = false) {
|
|
|
1237
1450
|
seenMessageIds.add(msgId);
|
|
1238
1451
|
}
|
|
1239
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
|
+
|
|
1240
1457
|
// A prompt another relay's agent sent carries `origin`: its header line is
|
|
1241
1458
|
// hidden (the badge below says the same), and a stray remote-relay mention
|
|
1242
1459
|
// hint never shows on any user bubble.
|
|
@@ -1258,6 +1475,7 @@ function createMessageNode(msg, msgId = null, force = false) {
|
|
|
1258
1475
|
div.dataset.messageTextFingerprint = fingerprint.text || '';
|
|
1259
1476
|
div.dataset.messageTimestamp = String(msg?.timestamp || '').trim();
|
|
1260
1477
|
if (fingerprint.sourceMessageId) div.dataset.sourceMessageId = fingerprint.sourceMessageId;
|
|
1478
|
+
setAgentExchangeRow(div, msg, msgId);
|
|
1261
1479
|
|
|
1262
1480
|
const label = msg.role === 'user' ? 'You' : '';
|
|
1263
1481
|
const { baseModelId, reasoningEffort } = splitVariantId(msg.model);
|
|
@@ -1361,7 +1579,14 @@ function createMessageNode(msg, msgId = null, force = false) {
|
|
|
1361
1579
|
const mainThoughts = thoughts.filter((t) => !t?.subagentRunId);
|
|
1362
1580
|
const activityHtml = mainActivities.length ? renderActivityMarkup(mainActivities, promotedBoundaryEntry) : '';
|
|
1363
1581
|
const thoughtsHtml = mainThoughts.length ? renderThoughtsMarkup(mainThoughts) : '';
|
|
1364
|
-
|
|
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
|
+
);
|
|
1365
1590
|
// Finished background workflows persisted with this assistant message
|
|
1366
1591
|
// (docs/plans/workflow-progress-tree.md, Phase 4). The template only
|
|
1367
1592
|
// reserves a placeholder; the cards are DOM-built below so digest text
|
|
@@ -1440,6 +1665,8 @@ function createMessageNode(msg, msgId = null, force = false) {
|
|
|
1440
1665
|
<div class="msg-label">${label}${modelTag}${reasoningTag}${modeTag}${autoTag}${continuationTag}${crossProviderTag}${usageTurnTag}${usageRemainingTag}${usageStaleTag} · ${fmtDate(msg.timestamp)}</div>`;
|
|
1441
1666
|
|
|
1442
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);
|
|
1443
1670
|
rewriteLocalAssetUrlsInNode(bubble, { preferDrive: msg.role === 'assistant' });
|
|
1444
1671
|
linkifyWorkspaceMentionsInNode(bubble);
|
|
1445
1672
|
// One collapsed "Finished background task" card per persisted run, sharing
|
|
@@ -1648,83 +1875,6 @@ export function renderActivityMarkup(activities, promotedBoundaryEntry = undefin
|
|
|
1648
1875
|
return `${progressHtml}${toolsHtml}`;
|
|
1649
1876
|
}
|
|
1650
1877
|
|
|
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
1878
|
// Places the live bubble directly under its owning user message, or leaves a
|
|
1729
1879
|
// bubble already in the transcript where it is when that row is not rendered.
|
|
1730
1880
|
// Idempotent so the reuse path can call it every tick: a bubble built before
|
|
@@ -1745,6 +1895,9 @@ function anchorThinkingBubble(div, messageId) {
|
|
|
1745
1895
|
if (next) el.insertBefore(div, next);
|
|
1746
1896
|
else el.appendChild(div);
|
|
1747
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();
|
|
1748
1901
|
} else if (div.parentNode !== el) {
|
|
1749
1902
|
el.appendChild(div);
|
|
1750
1903
|
}
|
|
@@ -1820,6 +1973,7 @@ export function showThinking(messageId = null, autoScroll = true) {
|
|
|
1820
1973
|
export function removeThinking() {
|
|
1821
1974
|
thinkingMessageId = null;
|
|
1822
1975
|
lastInFlightSnapshotKey = '';
|
|
1976
|
+
const hadBubble = !!document.getElementById('thinking-indicator');
|
|
1823
1977
|
const wasCompacting = !!document.getElementById('thinking-indicator')?.dataset?.compactBoundary;
|
|
1824
1978
|
unobserveThinkingBubble();
|
|
1825
1979
|
document.getElementById('thinking-indicator')?.remove();
|
|
@@ -1827,6 +1981,8 @@ export function removeThinking() {
|
|
|
1827
1981
|
// caller's same-tick work: a reply appended right after this call takes
|
|
1828
1982
|
// the line over (relabelled in place) instead of it being dropped first.
|
|
1829
1983
|
if (wasCompacting) queueMicrotask(syncSeparatorsNow);
|
|
1984
|
+
// A turn that ended without a reply (cancelled) stops saying "working".
|
|
1985
|
+
if (hadBubble) syncAgentExchangeFoldsNow();
|
|
1830
1986
|
}
|
|
1831
1987
|
|
|
1832
1988
|
// The live turn's compaction line: the bubble stamps the turn's compaction
|
|
@@ -2045,6 +2201,9 @@ export function restoreInFlightThinking(inFlight, autoScroll = true) {
|
|
|
2045
2201
|
return;
|
|
2046
2202
|
}
|
|
2047
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);
|
|
2048
2207
|
clearRelayStreamState();
|
|
2049
2208
|
setConversationTurnState(currentConvId, { messageId, status: 'processing' });
|
|
2050
2209
|
const activities = mergeRelayActivityTexts(
|
|
@@ -2086,14 +2245,19 @@ export function restoreInFlightThinking(inFlight, autoScroll = true) {
|
|
|
2086
2245
|
// Seed the per-thread stream text before the bubbles are built so both the
|
|
2087
2246
|
// reply preview and each subagent bubble repaint on reload.
|
|
2088
2247
|
const streamTextByThread = deriveInFlightStreamTextByThread(inFlight);
|
|
2089
|
-
if (streamTextByThread.main) {
|
|
2248
|
+
if (streamTextByThread.main && streamTextByThread.main.seq >= liveStreamSeq) {
|
|
2090
2249
|
relayStreamTextByMessageId.set(messageId, streamTextByThread.main.text);
|
|
2250
|
+
} else if (liveStreamText !== undefined) {
|
|
2251
|
+
relayStreamTextByMessageId.set(messageId, liveStreamText);
|
|
2091
2252
|
}
|
|
2092
2253
|
for (const [runId, entry] of streamTextByThread.bySubagentRunId) {
|
|
2093
2254
|
upsertSubagentRun({ subagentRunId: runId, messageId, conversationId: currentConvId });
|
|
2094
2255
|
setSubagentStreamText(runId, entry.text);
|
|
2095
2256
|
}
|
|
2096
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();
|
|
2097
2261
|
renderThinkingActivities();
|
|
2098
2262
|
renderThinkingThoughts();
|
|
2099
2263
|
renderRestoredSubagentBubbles(messageId);
|
|
@@ -2578,6 +2742,8 @@ export function applyRelayStreamEvent({
|
|
|
2578
2742
|
if (id !== thinkingMessageId) return true;
|
|
2579
2743
|
if (runId) renderSubagentStream(runId, text);
|
|
2580
2744
|
else renderThinkingStream();
|
|
2745
|
+
// Again once the text is in: it grew below the end the status scrolled to.
|
|
2746
|
+
if (autoScroll && !getThinkingFollowMode()) scrollBottom();
|
|
2581
2747
|
return true;
|
|
2582
2748
|
}
|
|
2583
2749
|
|
|
@@ -2905,6 +3071,8 @@ export function replaceRenderedMessage(msg, msgId) {
|
|
|
2905
3071
|
seenMessageIds.delete(id);
|
|
2906
3072
|
const fresh = createMessageNode(msg, id, true);
|
|
2907
3073
|
placeholder.replaceWith(fresh || existing);
|
|
3074
|
+
// The rebuilt row takes its merge and fold classes back.
|
|
3075
|
+
if (fresh) syncSeparatorsNow();
|
|
2908
3076
|
return Boolean(fresh);
|
|
2909
3077
|
}
|
|
2910
3078
|
|
|
@@ -3376,6 +3544,14 @@ export function appendMessage(msg, scroll = true, msgId = null, force = false, i
|
|
|
3376
3544
|
if (empty) empty.remove();
|
|
3377
3545
|
const node = createMessageNode(msg, msgId, force);
|
|
3378
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
|
+
}
|
|
3379
3555
|
// Insert without scrolling: the separator pass below can add a row above
|
|
3380
3556
|
// this message, and scrolling before it runs leaves the viewport a
|
|
3381
3557
|
// separator-height short of the bottom (which then reads as "not at
|
|
@@ -3491,6 +3667,91 @@ export function noteTranscriptJump() {
|
|
|
3491
3667
|
transcriptJumpEpoch += 1;
|
|
3492
3668
|
}
|
|
3493
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
|
+
|
|
3494
3755
|
export function renderMessages(msgs, scroll = true, meta = {}) {
|
|
3495
3756
|
const el = getMessagesElement();
|
|
3496
3757
|
if (!el) return false;
|
|
@@ -3571,11 +3832,17 @@ export function renderMessages(msgs, scroll = true, meta = {}) {
|
|
|
3571
3832
|
&& thinkingNode.dataset.conversationId === renderConversationKey) ? thinkingNode : null;
|
|
3572
3833
|
if (thinkingNodeInTranscript && !preservedThinkingNode) removeThinking();
|
|
3573
3834
|
if (!ordered.length && !preservedPendingNodes.length && !preservedThinkingNode) {
|
|
3574
|
-
|
|
3575
|
-
|
|
3576
|
-
|
|
3577
|
-
|
|
3578
|
-
|
|
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
|
+
}
|
|
3579
3846
|
resetConversationHistoryState();
|
|
3580
3847
|
renderRelayQuestions();
|
|
3581
3848
|
renderRelayBoards();
|
|
@@ -3680,6 +3947,8 @@ export function focusConversationMessageById(messageId, { behavior = 'smooth', b
|
|
|
3680
3947
|
if (!id) return false;
|
|
3681
3948
|
const el = getMessagesElement();
|
|
3682
3949
|
if (!el) return false;
|
|
3950
|
+
// A message in a folded agent exchange is shown before it is scrolled to.
|
|
3951
|
+
revealAgentExchangeMessage(id);
|
|
3683
3952
|
const target = el.querySelector(`.msg[data-message-id="${CSS.escape(id)}"]`);
|
|
3684
3953
|
if (!target) return false;
|
|
3685
3954
|
target.scrollIntoView({ behavior, block, inline: 'nearest' });
|
|
@@ -3733,6 +4002,13 @@ export async function sendMessage() {
|
|
|
3733
4002
|
return;
|
|
3734
4003
|
}
|
|
3735
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
|
+
}
|
|
3736
4012
|
closeSlashAutocomplete();
|
|
3737
4013
|
closeMentionAutocomplete();
|
|
3738
4014
|
|