@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.
Files changed (85) hide show
  1. package/CHANGELOG.md +247 -0
  2. package/README.md +78 -9
  3. package/bin/bat/oar.bat +46 -46
  4. package/package.json +2 -2
  5. package/server/README.md +286 -3
  6. package/server/claude-worker/claude-remote-relay-tool.mjs +10 -0
  7. package/server/claude-worker/claude-session-process.mjs +35 -1
  8. package/server/claude-worker/claude-turn-publisher.mjs +3 -0
  9. package/server/claude-worker/sdk-message-normalizer.mjs +31 -4
  10. package/server/claude-worker/subagent-timeline-recorder.mjs +572 -0
  11. package/server/copilot-worker/copilot-sdk-session-process.mjs +3 -1
  12. package/server/cursor-worker/cursor-sdk-adapter.mjs +56 -2
  13. package/server/db-schema.mjs +36 -0
  14. package/server/migrations/0009-subagent-timeline.mjs +135 -0
  15. package/server/migrations/0010-subagent-timeline-pruning.mjs +89 -0
  16. package/server/migrations/0011-relay-event-subscriptions.mjs +125 -0
  17. package/server/migrations/0012-cross-relay-event-subscriptions.mjs +116 -0
  18. package/server/public/app/agent-exchange-fold-view.mjs +248 -0
  19. package/server/public/app/api-client.js +61 -0
  20. package/server/public/app/archive-confirm.mjs +28 -0
  21. package/server/public/app/ask-user-view.js +3 -0
  22. package/server/public/app/attachments-view.js +516 -6
  23. package/server/public/app/background-tasks-view.mjs +18 -26
  24. package/server/public/app/bootstrap.js +186 -35
  25. package/server/public/app/chat-actions-menu-state.mjs +116 -0
  26. package/server/public/app/claude-cloud-conversation-ui.js +10 -22
  27. package/server/public/app/composer-control-state.mjs +16 -0
  28. package/server/public/app/composer-keyboard.mjs +54 -0
  29. package/server/public/app/conversation-view.js +361 -85
  30. package/server/public/app/explorer-edit-state.mjs +68 -0
  31. package/server/public/app/host-suspend-ui.js +6 -3
  32. package/server/public/app/journal-view.js +146 -26
  33. package/server/public/app/no-conversation-state.mjs +58 -0
  34. package/server/public/app/provider-readiness.mjs +120 -0
  35. package/server/public/app/relay-board-view.js +3 -0
  36. package/server/public/app/relay-event-subscriptions-ui.js +184 -0
  37. package/server/public/app/relay-event-view.mjs +234 -0
  38. package/server/public/app/settings-modal.js +53 -1
  39. package/server/public/app/socket-handlers.js +59 -28
  40. package/server/public/app/store.js +19 -2
  41. package/server/public/app/subagent-row.mjs +138 -0
  42. package/server/public/app/subagent-timeline-view.mjs +186 -0
  43. package/server/public/app/subagent-viewer-watch.mjs +50 -0
  44. package/server/public/app/subagent-viewer.mjs +534 -0
  45. package/server/public/app/task-row-format.mjs +29 -0
  46. package/server/public/app/thinking-follow.js +4 -0
  47. package/server/public/app/transcript-jump-bottom-view.mjs +290 -0
  48. package/server/public/app/transcript-jump-bottom.mjs +99 -0
  49. package/server/public/app/transcript-reload-freshness.mjs +25 -0
  50. package/server/public/app/update-ui.js +1 -1
  51. package/server/public/app/viewer-history.mjs +5 -3
  52. package/server/public/index.html +396 -5
  53. package/server/repositories/message-repository.mjs +29 -3
  54. package/server/repositories/question-repository.mjs +9 -3
  55. package/server/repositories/remote-relay-repository.mjs +6 -2
  56. package/server/repositories/subagent-timeline-statements.mjs +170 -0
  57. package/server/routes/ask-user-routes.mjs +3 -0
  58. package/server/routes/messages-routes.mjs +515 -50
  59. package/server/routes/relay-event-routes.mjs +157 -0
  60. package/server/routes/remote-relay-routes.mjs +15 -0
  61. package/server/routes/sessions-routes.mjs +72 -0
  62. package/server/server-runtime.mjs +245 -62
  63. package/server/services/explorer-entry-name.mjs +26 -0
  64. package/server/services/explorer-mutations.mjs +122 -0
  65. package/server/services/preview-proxy-server.mjs +41 -23
  66. package/server/services/push-dispatch-service.mjs +18 -0
  67. package/server/services/relay-event-link-service.mjs +289 -0
  68. package/server/services/relay-event-service.mjs +1935 -0
  69. package/server/services/relay-shutdown-gate.mjs +31 -0
  70. package/server/services/remote-relay-caller-context.mjs +52 -7
  71. package/server/services/remote-relay-dispatcher.mjs +494 -2
  72. package/server/services/remote-relay-registry-service.mjs +11 -1
  73. package/server/services/session-activity-tracker.mjs +487 -0
  74. package/server/services/session-history-refresh-service.mjs +7 -0
  75. package/server/services/stale-attempt-log-limiter.mjs +96 -0
  76. package/server/services/stale-recovery-clock-guard.mjs +57 -0
  77. package/server/services/stale-subagent-run-sweep.mjs +109 -0
  78. package/server/services/subagent-record-retention.mjs +164 -0
  79. package/server/services/subagent-timeline-service.mjs +328 -0
  80. package/server/services/subagent-viewer-rooms.mjs +119 -0
  81. package/server/services/upgrade-socket-guard.mjs +16 -0
  82. package/shared/relay-events.mjs +371 -0
  83. package/shared/remote-relay-contract.mjs +157 -5
  84. package/shared/subagent-record-retention.mjs +31 -0
  85. 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
- const subagentHtml = renderSubagentRunsMarkup(subagentRuns, activities, thoughts);
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
- el.innerHTML = `<div class="empty-state">
3575
- <div class="icon">${currentConvId ? '💬' : '🚀'}</div>
3576
- <h3>${currentConvId ? 'No messages yet' : 'New Conversation'}</h3>
3577
- <p>${currentConvId ? 'Start the conversation below' : 'Type your first message below'}</p>
3578
- </div>`;
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