@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.
Files changed (107) hide show
  1. package/CHANGELOG.md +478 -0
  2. package/README.md +129 -39
  3. package/bin/bat/oar.bat +46 -46
  4. package/bin/oar.js +814 -228
  5. package/package.json +5 -6
  6. package/server/README.md +306 -4
  7. package/server/claude-worker/claude-remote-relay-tool.mjs +10 -0
  8. package/server/claude-worker/claude-session-process.mjs +35 -1
  9. package/server/claude-worker/claude-turn-publisher.mjs +3 -0
  10. package/server/claude-worker/sdk-message-normalizer.mjs +31 -4
  11. package/server/claude-worker/subagent-timeline-recorder.mjs +572 -0
  12. package/server/copilot-worker/copilot-sdk-session-process.mjs +3 -1
  13. package/server/cursor-worker/cursor-sdk-adapter.mjs +56 -2
  14. package/server/db-schema.mjs +44 -0
  15. package/server/migrations/0008-message-pins.mjs +85 -0
  16. package/server/migrations/0009-subagent-timeline.mjs +135 -0
  17. package/server/migrations/0010-subagent-timeline-pruning.mjs +89 -0
  18. package/server/migrations/0011-relay-event-subscriptions.mjs +125 -0
  19. package/server/migrations/0012-cross-relay-event-subscriptions.mjs +116 -0
  20. package/server/public/app/agent-exchange-fold-view.mjs +248 -0
  21. package/server/public/app/api-client.js +91 -0
  22. package/server/public/app/archive-confirm.mjs +28 -0
  23. package/server/public/app/ask-user-view.js +3 -0
  24. package/server/public/app/attachments-view.js +1024 -131
  25. package/server/public/app/back-guard.mjs +58 -0
  26. package/server/public/app/background-tasks-view.mjs +18 -26
  27. package/server/public/app/bootstrap.js +230 -35
  28. package/server/public/app/chat-actions-menu-state.mjs +116 -0
  29. package/server/public/app/claude-cloud-conversation-ui.js +10 -22
  30. package/server/public/app/composer-control-state.mjs +16 -0
  31. package/server/public/app/composer-keyboard.mjs +54 -0
  32. package/server/public/app/conversation-view.js +471 -110
  33. package/server/public/app/explorer-edit-state.mjs +68 -0
  34. package/server/public/app/external-link-policy.mjs +4 -1
  35. package/server/public/app/file-version.mjs +58 -0
  36. package/server/public/app/host-suspend-ui.js +6 -3
  37. package/server/public/app/journal-view.js +161 -27
  38. package/server/public/app/no-conversation-state.mjs +58 -0
  39. package/server/public/app/pinned-messages-view.mjs +253 -0
  40. package/server/public/app/provider-readiness.mjs +120 -0
  41. package/server/public/app/pwa-install.js +1 -1
  42. package/server/public/app/relay-board-view.js +3 -0
  43. package/server/public/app/relay-dot-state.mjs +8 -1
  44. package/server/public/app/relay-event-subscriptions-ui.js +184 -0
  45. package/server/public/app/relay-event-view.mjs +234 -0
  46. package/server/public/app/router.js +8 -2
  47. package/server/public/app/settings-modal.js +53 -1
  48. package/server/public/app/socket-handlers.js +65 -28
  49. package/server/public/app/store.js +25 -2
  50. package/server/public/app/subagent-row.mjs +138 -0
  51. package/server/public/app/subagent-timeline-view.mjs +186 -0
  52. package/server/public/app/subagent-viewer-watch.mjs +50 -0
  53. package/server/public/app/subagent-viewer.mjs +534 -0
  54. package/server/public/app/task-row-format.mjs +29 -0
  55. package/server/public/app/thinking-follow.js +4 -0
  56. package/server/public/app/transcript-jump-bottom-view.mjs +290 -0
  57. package/server/public/app/transcript-jump-bottom.mjs +99 -0
  58. package/server/public/app/transcript-reload-freshness.mjs +25 -0
  59. package/server/public/app/update-ui.js +1 -1
  60. package/server/public/app/viewer-chat-gallery.mjs +59 -0
  61. package/server/public/app/viewer-controls-state.mjs +59 -0
  62. package/server/public/app/viewer-gallery.mjs +46 -0
  63. package/server/public/app/viewer-gestures.mjs +105 -0
  64. package/server/public/app/viewer-history.mjs +45 -0
  65. package/server/public/app/viewer-tap.mjs +112 -0
  66. package/server/public/index.html +700 -59
  67. package/server/repositories/message-pin-statements.mjs +39 -0
  68. package/server/repositories/message-repository.mjs +34 -3
  69. package/server/repositories/question-repository.mjs +9 -3
  70. package/server/repositories/remote-relay-repository.mjs +6 -2
  71. package/server/repositories/subagent-timeline-statements.mjs +170 -0
  72. package/server/routes/ask-user-routes.mjs +3 -0
  73. package/server/routes/messages-routes.mjs +548 -111
  74. package/server/routes/relay-event-routes.mjs +157 -0
  75. package/server/routes/remote-relay-routes.mjs +15 -0
  76. package/server/routes/sessions-routes.mjs +164 -24
  77. package/server/server-runtime.mjs +274 -62
  78. package/server/services/cloudflared-tunnel-service.mjs +139 -41
  79. package/server/services/explorer-entry-name.mjs +26 -0
  80. package/server/services/explorer-mutations.mjs +122 -0
  81. package/server/services/file-serving.mjs +155 -0
  82. package/server/services/message-pin-list.mjs +52 -0
  83. package/server/services/oar-cli-helpers.mjs +139 -4
  84. package/server/services/oar-cli-menu.mjs +240 -0
  85. package/server/services/oar-cli-wsl.mjs +129 -0
  86. package/server/services/preview-proxy-server.mjs +41 -23
  87. package/server/services/push-dispatch-service.mjs +18 -0
  88. package/server/services/relay-event-link-service.mjs +289 -0
  89. package/server/services/relay-event-service.mjs +1935 -0
  90. package/server/services/relay-shutdown-gate.mjs +31 -0
  91. package/server/services/remote-relay-caller-context.mjs +52 -7
  92. package/server/services/remote-relay-dispatcher.mjs +494 -2
  93. package/server/services/remote-relay-registry-service.mjs +11 -1
  94. package/server/services/safe-served-content.mjs +61 -11
  95. package/server/services/session-activity-tracker.mjs +487 -0
  96. package/server/services/session-history-refresh-service.mjs +31 -0
  97. package/server/services/stale-attempt-log-limiter.mjs +96 -0
  98. package/server/services/stale-recovery-clock-guard.mjs +57 -0
  99. package/server/services/stale-subagent-run-sweep.mjs +109 -0
  100. package/server/services/subagent-record-retention.mjs +164 -0
  101. package/server/services/subagent-timeline-service.mjs +328 -0
  102. package/server/services/subagent-viewer-rooms.mjs +119 -0
  103. package/server/services/upgrade-socket-guard.mjs +16 -0
  104. package/shared/relay-events.mjs +371 -0
  105. package/shared/remote-relay-contract.mjs +157 -5
  106. package/shared/subagent-record-retention.mjs +31 -0
  107. 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, openDriveFilePreview, openWorkspaceFilePreview, openUploadedAttachmentViewer } from './attachments-view.js';
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
- 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
+ );
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
- // Clicking an inline-embedded image in a message bubble opens it in the file
3273
- // viewer modal (zoom + Download + Copy), the same viewer the repo browser
3274
- // uses. Attachment thumbnails keep their own handler; video/audio keep their
3275
- // native controls untouched.
3276
- function handleEmbeddedImageClick(event) {
3277
- const img = event.target.closest?.('.msg-bubble img');
3278
- if (!img || img.closest('.msg-attachment')) return;
3279
- if (IS_SHARED_VIEW) return; // the file routes are cookie-authed; shared viewers would only see a failed load
3280
- const src = String(img.getAttribute('src') || '').trim();
3281
- if (!src) return;
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 target = parseAppFileHref(src);
3285
- if (target?.kind === 'drive') {
3286
- void openDriveFilePreview(target.path);
3287
- return;
3288
- }
3289
- if (target?.kind === 'workspace') {
3290
- void openWorkspaceFilePreview(target.path);
3291
- return;
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
- // data: thumbnails and external http(s) images: show them directly.
3294
- const dataMime = /^data:(image\/[a-z0-9.+-]+)/i.exec(src)?.[1] || '';
3295
- openUploadedAttachmentViewer(img.getAttribute('alt') || 'image', src, dataMime || 'image/*');
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', handleEmbeddedImageClick);
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
- el.innerHTML = `<div class="empty-state">
3496
- <div class="icon">${currentConvId ? '💬' : '🚀'}</div>
3497
- <h3>${currentConvId ? 'No messages yet' : 'New Conversation'}</h3>
3498
- <p>${currentConvId ? 'Start the conversation below' : 'Type your first message below'}</p>
3499
- </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
+ }
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
- const target = el.querySelector(`[data-message-id="${id}"]`);
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