@oar-sh/oar 0.9.10 → 0.9.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/CHANGELOG.md +314 -1
  2. package/README.md +156 -972
  3. package/package.json +13 -13
  4. package/server/README.md +95 -22
  5. package/server/claude-worker/claude-session-process.mjs +89 -11
  6. package/server/claude-worker/claude-session-worker.mjs +9 -1
  7. package/server/copilot-worker/copilot-sdk-session-process.mjs +76 -11
  8. package/server/copilot-worker/copilot-sdk-session-worker.mjs +6 -0
  9. package/server/db-schema.mjs +59 -2
  10. package/server/grok-worker/acp-client.mjs +2 -1
  11. package/server/grok-worker/acp-host-services.mjs +4 -3
  12. package/server/migrations/0013-previews.mjs +130 -0
  13. package/server/migrations/0014-conversation-groups.mjs +89 -0
  14. package/server/migrations/0015-conversation-group-position.mjs +76 -0
  15. package/server/migrations/0016-share-access-log.mjs +110 -0
  16. package/server/public/app/about-ui.js +73 -0
  17. package/server/public/app/about-view.mjs +234 -0
  18. package/server/public/app/action-confirmations.js +31 -14
  19. package/server/public/app/agent-exchange-fold-view.mjs +40 -101
  20. package/server/public/app/api-client.js +251 -16
  21. package/server/public/app/ask-user-view.js +17 -15
  22. package/server/public/app/bootstrap.js +158 -32
  23. package/server/public/app/chat-actions-menu-state.mjs +20 -10
  24. package/server/public/app/context-menu.mjs +90 -6
  25. package/server/public/app/conversation-group-drag.mjs +191 -0
  26. package/server/public/app/conversation-group-order.mjs +265 -0
  27. package/server/public/app/conversation-group-state.mjs +100 -0
  28. package/server/public/app/conversation-list-drag.mjs +449 -0
  29. package/server/public/app/conversation-turn-state.mjs +81 -0
  30. package/server/public/app/conversation-view.js +136 -93
  31. package/server/public/app/cwd-picker.js +1 -1
  32. package/server/public/app/hidden-messages-view.mjs +312 -0
  33. package/server/public/app/host-suspend-ui.js +7 -1
  34. package/server/public/app/journal-view.js +787 -22
  35. package/server/public/app/preview-cards.mjs +117 -31
  36. package/server/public/app/preview-command.mjs +10 -1
  37. package/server/public/app/relay-board-view.js +19 -6
  38. package/server/public/app/relay-restart-error.mjs +16 -0
  39. package/server/public/app/settings-modal.js +40 -8
  40. package/server/public/app/settings-tabs.js +1 -1
  41. package/server/public/app/share-format.mjs +49 -0
  42. package/server/public/app/share-preview-marks.mjs +167 -0
  43. package/server/public/app/share-state.js +109 -0
  44. package/server/public/app/shared-conversations-settings.js +480 -0
  45. package/server/public/app/socket-handlers.js +80 -14
  46. package/server/public/app/store.js +96 -30
  47. package/server/public/app/subagent-row.mjs +3 -3
  48. package/server/public/app/subagent-viewer.mjs +30 -6
  49. package/server/public/app/thinking-follow.js +0 -4
  50. package/server/public/app/transcript-jump-bottom-view.mjs +194 -20
  51. package/server/public/app/transcript-jump-bottom.mjs +132 -21
  52. package/server/public/app/transcript-viewport-anchor.mjs +140 -0
  53. package/server/public/index.html +386 -15
  54. package/server/relay-tools.md +2 -1
  55. package/server/relay.mjs +2 -1
  56. package/server/repositories/conversation-group-repository.mjs +444 -0
  57. package/server/repositories/message-repository.mjs +1 -0
  58. package/server/repositories/session-repository.mjs +12 -37
  59. package/server/repositories/share-repository.mjs +322 -0
  60. package/server/repositories/share-visibility-statements.mjs +10 -0
  61. package/server/routes/about-routes.mjs +15 -0
  62. package/server/routes/cache-routes.mjs +4 -0
  63. package/server/routes/conversation-group-routes.mjs +171 -0
  64. package/server/routes/messages-routes.mjs +184 -65
  65. package/server/routes/preview-routes.mjs +9 -1
  66. package/server/routes/sessions-routes.mjs +771 -193
  67. package/server/server-runtime.mjs +316 -100
  68. package/server/services/about-service.mjs +461 -0
  69. package/server/services/background-task-store.mjs +128 -0
  70. package/server/services/background-work-view.mjs +228 -0
  71. package/server/services/cache-rebuild-service.mjs +30 -0
  72. package/server/services/delete-archive-service.mjs +4 -1
  73. package/server/services/detached-worker-watch-service.mjs +150 -0
  74. package/server/services/hidden-message-list.mjs +21 -0
  75. package/server/services/host-suspend-command.mjs +2 -1
  76. package/server/services/message-pin-list.mjs +1 -1
  77. package/server/services/preview-health-probe.mjs +8 -1
  78. package/server/services/preview-proxy-server.mjs +8 -4
  79. package/server/services/preview-registry-service.mjs +676 -26
  80. package/server/services/preview-static-handler.mjs +40 -7
  81. package/server/services/preview-store.mjs +274 -0
  82. package/server/services/rate-limit-service.mjs +12 -1
  83. package/server/services/relay-auth.mjs +183 -0
  84. package/server/services/relay-cli-launcher-service.mjs +3 -1
  85. package/server/services/relay-event-service.mjs +17 -0
  86. package/server/services/remote-relay-dispatcher.mjs +57 -5
  87. package/server/services/request-boundary.mjs +183 -0
  88. package/server/services/session-activity-tracker.mjs +227 -19
  89. package/server/services/session-worker-launch-service.mjs +190 -6
  90. package/server/services/session-worker-process-service.mjs +10 -6
  91. package/server/services/share-access-log-service.mjs +123 -0
  92. package/server/services/share-path-redaction.mjs +424 -0
  93. package/server/services/status-event-service.mjs +7 -3
  94. package/server/services/tunnel-worker-path-guard.mjs +24 -41
  95. package/server/services/windows-autostart-service.mjs +6 -1
  96. package/server/services/windows-boot-autostart-service.mjs +207 -23
  97. package/shared/preview-tool-core.mjs +15 -5
  98. package/shared/remote-relay-contract.mjs +3 -1
  99. package/shared/share-path-privacy.mjs +30 -0
  100. package/shared/windows-system-tools.mjs +106 -0
  101. package/shared/worker-runtime/background-task-digest.mjs +30 -0
  102. package/shared/worker-runtime/heartbeat.mjs +24 -0
  103. package/shared/worker-runtime/kept-posts.mjs +83 -0
  104. package/shared/worker-runtime/worker-websocket-link.mjs +47 -15
@@ -23,7 +23,6 @@ import {
23
23
  updateWorkspaceRootHints,
24
24
  applyContextUsageBar,
25
25
  scrollBottom,
26
- scrollBottomAfterSend,
27
26
  isMobileComposerViewport,
28
27
  releaseComposerFocusAfterSend,
29
28
  autoResize,
@@ -47,9 +46,6 @@ import {
47
46
  getSessionWorkerState,
48
47
  openSummaryModal,
49
48
  closeSummaryModal,
50
- parseTimestampMs,
51
- relayQuestions,
52
- relayBoards,
53
49
  toggleSidebar,
54
50
  } from './store.js';
55
51
  import { getPendingQuestionCountsByConversation } from './ask-user-view.js';
@@ -62,6 +58,7 @@ import { buildWorkflowRunCard } from './background-tasks-view.mjs';
62
58
  import { mergeLiveSubagentRun, subagentRowsMarkup } from './subagent-row.mjs';
63
59
  import { rememberSubagentRuns, syncSubagentRowClock } from './subagent-viewer.mjs';
64
60
  import { isMessagePinned, toggleMessagePin } from './pinned-messages-view.mjs';
61
+ import { isMessageHiddenFromShares, setConversationHiddenMessages } from './hidden-messages-view.mjs';
65
62
  import { parsePreviewCommand, runPreviewCommand } from './preview-command.mjs';
66
63
  import { buildTranscriptPreviewCard } from './preview-cards.mjs';
67
64
  import { closeSlashAutocomplete, handleSlashAutocompleteKey, updateSlashAutocomplete } from './slash-autocomplete.mjs';
@@ -71,7 +68,6 @@ import { isRelayEventMessage, renderRelayEventRowHtml } from './relay-event-view
71
68
  import {
72
69
  AGENT_EXCHANGE_CLOSED_CLASS,
73
70
  AGENT_EXCHANGE_HEAD_CLASS,
74
- AGENT_EXCHANGE_MEMBER_CLASS,
75
71
  agentExchangeFoldOpen,
76
72
  agentExchangeRowOf,
77
73
  agentExchangeSummary,
@@ -135,6 +131,7 @@ import {
135
131
  shouldApplyIncomingDraftToComposer,
136
132
  } from './conversation-draft-sync.mjs';
137
133
  import { isChatInteractionHeld, selectionIntersectsNode } from './selection-guard.mjs';
134
+ import { captureReadingBlock, restoreReadingBlock } from './transcript-viewport-anchor.mjs';
138
135
  import {
139
136
  applyThinkingFollow,
140
137
  getThinkingFollowMode,
@@ -145,6 +142,7 @@ import {
145
142
  toggleThinkingFollowMode,
146
143
  unobserveThinkingBubble,
147
144
  } from './thinking-follow.js';
145
+ import { beginOwnSend, followsTranscriptEnd, nameOwnSendConversation, ownSendFollows, scrollAfterOwnSend } from './transcript-jump-bottom-view.mjs';
148
146
  import { buildInFlightSnapshotKey } from './in-flight-snapshot.mjs';
149
147
  import { computeStablePrefixLength, planListPatch } from './streaming-dom-patch.mjs';
150
148
  import { prefixToolActivityEmoji } from './tool-activity-emoji.mjs';
@@ -1289,38 +1287,20 @@ function createRelayEventNode(msg, msgId) {
1289
1287
  return div;
1290
1288
  }
1291
1289
 
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.
1290
+ // Agent exchanges (agent-exchange-fold-view.mjs): an agent's prompt folds
1291
+ // into one row; this session's reply to it always shows. What the fold pass
1292
+ // needs of a message is kept on its node when the row is built, so the pass
1293
+ // reads the transcript itself and not the payload it came from.
1296
1294
  const AGENT_EXCHANGE_ROW_KEY = '__agentExchangeRow';
1297
- // The user's toggle of an exchange, by its prompt's id: true opened, false
1295
+ // The user's toggle of a folded prompt, by its id: true opened, false
1298
1296
  // closed, unset none (agentExchangeFoldOpen: open while it works, else
1299
1297
  // folded). Kept through any re-render.
1300
1298
  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();
1299
+ // Each folded prompt's status, by its id, as of the last pass.
1308
1300
  let agentExchangeStatusByPromptId = new Map();
1309
1301
 
1310
1302
  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;
1303
+ div[AGENT_EXCHANGE_ROW_KEY] = agentExchangeRowOf(msg, { id: msgId || msg?.id || '' });
1324
1304
  }
1325
1305
 
1326
1306
  // The rows the plan reads, in order, and the node of each. The live bubble
@@ -1345,58 +1325,45 @@ function agentExchangeTranscriptRows(el) {
1345
1325
  }
1346
1326
 
1347
1327
  /**
1348
- * Fold the transcript's agent exchanges. One idempotent pass, run after every
1328
+ * Fold the transcript's agent prompts. One idempotent pass, run after every
1349
1329
  * render and update of the list like the separator and merge passes. The
1350
1330
  * 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.
1331
+ * insertion path has to place it; closed, a class hides the prompt's bubble.
1332
+ * The replies and the live bubble are read for the row's status and are
1333
+ * otherwise left alone.
1353
1334
  */
1354
1335
  function syncAgentExchangeFolds(el = getMessagesElement()) {
1355
1336
  if (!el) return;
1356
1337
  const { rows, nodes } = agentExchangeTranscriptRows(el);
1357
1338
  const plan = planAgentExchangeFolds(rows, {
1358
1339
  runningIds: [getActiveTurnForConversation(currentConvId)?.messageId],
1359
- openCardIds: openCardMessageIds(),
1360
- keptShownIds: agentExchangesShownForUser,
1361
1340
  });
1362
1341
  const states = new Map();
1363
- const promptIdByMessageId = new Map();
1364
1342
  const statusByPromptId = new Map();
1365
1343
  for (const group of plan) {
1366
- if (group.transient) agentExchangesShownForUser.add(group.promptId);
1367
1344
  if (!group.fold) continue;
1368
1345
  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);
1346
+ states.set(nodes.get(group.prompt), { group, open });
1371
1347
  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
1348
  }
1377
- agentExchangePromptIdByMessageId = promptIdByMessageId;
1378
1349
  agentExchangeStatusByPromptId = statusByPromptId;
1379
1350
  for (const node of el.children) {
1380
1351
  if (node.classList?.contains('msg')) applyAgentExchangeState(node, states.get(node) || null);
1381
1352
  }
1382
1353
  }
1383
1354
 
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.
1355
+ // For the paths that change the list outside a render: the live bubble
1356
+ // coming and going. A bulk render runs the pass once at its end.
1386
1357
  export function syncAgentExchangeFoldsNow() {
1387
1358
  if (separatorSyncSuspended > 0) return;
1388
1359
  syncAgentExchangeFolds();
1389
1360
  }
1390
1361
 
1391
1362
  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);
1363
+ node.classList.toggle(AGENT_EXCHANGE_HEAD_CLASS, !!state);
1394
1364
  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
1365
  const existing = node.querySelector(':scope > .agent-exchange-row');
1399
- if (!state?.head) {
1366
+ if (!state) {
1400
1367
  existing?.remove();
1401
1368
  return;
1402
1369
  }
@@ -1430,11 +1397,11 @@ function onAgentExchangeToggle(row) {
1430
1397
  syncAgentExchangeFolds();
1431
1398
  }
1432
1399
 
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.
1400
+ // A search result, a pin or a jump to a folded prompt opens it first, so
1401
+ // the message jumped to is the one on screen.
1435
1402
  function revealAgentExchangeMessage(messageId) {
1436
- const promptId = agentExchangePromptIdByMessageId.get(String(messageId || '').trim());
1437
- if (!promptId || agentExchangeFoldChoices.get(promptId) === true) return;
1403
+ const promptId = String(messageId || '').trim();
1404
+ if (!agentExchangeStatusByPromptId.has(promptId) || agentExchangeFoldChoices.get(promptId) === true) return;
1438
1405
  agentExchangeFoldChoices.set(promptId, true);
1439
1406
  syncAgentExchangeFolds();
1440
1407
  }
@@ -1523,10 +1490,11 @@ function createMessageNode(msg, msgId = null, force = false) {
1523
1490
  // (e.g. the Copilot relay answering a Cursor conversation) must be visible,
1524
1491
  // not silent: it ran on another plan than the header indicates.
1525
1492
  const executedProvider = String(msg?.executedProvider || '').trim().toLowerCase();
1526
- const boundProvider = String(conversations[currentConvId]?.runtimeProviderType || 'github').trim().toLowerCase();
1493
+ const boundProvider = String(conversations[currentConvId]?.runtimeProviderType || '').trim().toLowerCase();
1527
1494
  // 'unknown' means the responder identity did not resolve, not that another
1528
- // provider ran the turn — no chip for it.
1529
- const crossProviderTag = (msg.role === 'assistant' && executedProvider && executedProvider !== 'unknown' && executedProvider !== boundProvider)
1495
+ // provider ran the turn — no chip for it. Nor without a bound provider to
1496
+ // compare with (a shared view of a conversation that has none).
1497
+ const crossProviderTag = (msg.role === 'assistant' && executedProvider && executedProvider !== 'unknown' && boundProvider && executedProvider !== boundProvider)
1530
1498
  ? ` <span class="msg-provider-mismatch" title="This turn was executed by the ${escHtml(executedProvider)} provider, not the conversation's ${escHtml(boundProvider)} provider.">ran on ${escHtml(executedProvider)}</span>`
1531
1499
  : '';
1532
1500
  const usage = (msg.role === 'assistant' && msg?.usage && typeof msg.usage === 'object') ? msg.usage : null;
@@ -1585,7 +1553,7 @@ function createMessageNode(msg, msgId = null, force = false) {
1585
1553
  rememberSubagentRuns(subagentRuns);
1586
1554
  const subagentHtml = subagentRowsMarkup(
1587
1555
  subagentRuns.map((run) => mergeLiveSubagentRun(run, getSubagentRun(run.subagentRunId))),
1588
- { clickable: !IS_SHARED_VIEW },
1556
+ { clickable: true },
1589
1557
  );
1590
1558
  // Finished background workflows persisted with this assistant message
1591
1559
  // (docs/plans/workflow-progress-tree.md, Phase 4). The template only
@@ -1614,7 +1582,9 @@ function createMessageNode(msg, msgId = null, force = false) {
1614
1582
  && cancellableSteerIdsForConversation(currentConvId).has(msgId);
1615
1583
  const showsCancelButton = isQueuedUserMessage || isCancellableSteer;
1616
1584
  const isCancelInFlight = showsCancelButton && bubbleCancelInFlight.has(msgId);
1617
- const hiddenFromShares = msg?.hiddenFromShares === true;
1585
+ // The conversation's hidden list once it is known (a change on another
1586
+ // device arrives there), else the message's own mark.
1587
+ const hiddenFromShares = isMessageHiddenFromShares(currentConvId, msgId) ?? (msg?.hiddenFromShares === true);
1618
1588
  const activeTurnMessageId = String(getActiveTurnForConversation(currentConvId)?.messageId || '').trim();
1619
1589
  const sourceMessageId = String(msg?.sourceMessageId || '').trim();
1620
1590
  const belongsToActiveTurn = !!activeTurnMessageId
@@ -1627,8 +1597,8 @@ function createMessageNode(msg, msgId = null, force = false) {
1627
1597
  const shareVisibilityActionHtml = canToggleShareVisibility
1628
1598
  ? `<div class="msg-share-visibility">
1629
1599
  ${buildMessagePinControlsHtml(msgId, pinned)}
1630
- ${hiddenFromShares ? '<span class="msg-hidden-label">Hidden from shared viewers</span>' : ''}
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>
1600
+ ${hiddenFromShares ? HIDDEN_LABEL_HTML : ''}
1601
+ <button type="button" class="msg-share-visibility-btn" data-action="toggle-share-visibility" data-message-id="${escHtml(msgId)}" data-hidden-from-shares="${hiddenFromShares ? 'true' : 'false'}" title="${shareVisibilityButtonTitle(hiddenFromShares)}">${hiddenFromShares ? 'Unhide' : 'Hide'}</button>
1632
1602
  </div>`
1633
1603
  : '';
1634
1604
  // A terminal failure whose fix lives in the relay UI gets the fix as a button
@@ -1763,21 +1733,12 @@ function prependMessageNodes(msgs) {
1763
1733
  return { inserted, firstMessageId };
1764
1734
  }
1765
1735
 
1736
+ // A shared view's tool lines arrive filtered by the relay's path privacy
1737
+ // setting (server/services/share-path-redaction.mjs); they are shown as sent.
1766
1738
  export function decorateActivityText(text) {
1767
1739
  const value = String(text || '').trim();
1768
1740
  if (!value) return '';
1769
- const maskSharedPathSegments = (input) => {
1770
- const source = String(input || '');
1771
- const tokenMasked = source.replace(/@(file|folder):([^\s`]+)/gi, (_m, kind, rawPath) => {
1772
- const normalized = String(rawPath || '').replace(/\\/g, '/').replace(/\/+$/, '');
1773
- const segments = normalized.split('/').filter(Boolean);
1774
- const basename = segments[segments.length - 1] || normalized;
1775
- return `@${String(kind || '').toLowerCase()}:${basename}`;
1776
- });
1777
- return tokenMasked.replace(/([A-Za-z]:)?(?:[\\/~.]?[\\/])(?:[^\\/\s]+[\\/])+([^\\/\s]+)/g, (_m, _prefix, basename) => basename);
1778
- };
1779
- const sharedSafeValue = IS_SHARED_VIEW ? maskSharedPathSegments(value) : value;
1780
- return prefixToolActivityEmoji(sharedSafeValue);
1741
+ return prefixToolActivityEmoji(value);
1781
1742
  }
1782
1743
 
1783
1744
  export function renderThoughtsMarkup(thoughts) {
@@ -1895,8 +1856,7 @@ function anchorThinkingBubble(div, messageId) {
1895
1856
  if (next) el.insertBefore(div, next);
1896
1857
  else el.appendChild(div);
1897
1858
  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.
1859
+ // A folded agent prompt's row says the turn is working.
1900
1860
  else syncAgentExchangeFoldsNow();
1901
1861
  } else if (div.parentNode !== el) {
1902
1862
  el.appendChild(div);
@@ -1985,6 +1945,43 @@ export function removeThinking() {
1985
1945
  if (hadBubble) syncAgentExchangeFoldsNow();
1986
1946
  }
1987
1947
 
1948
+ // The turn ends under a reader who is not at the end: the live bubble is
1949
+ // taken off the page and the reply row is put in its place. The page is
1950
+ // shorter for a moment (scrollTop can be clamped), and the reply is not as
1951
+ // tall as the bubble was (its thoughts are folded). Whatever the reader looks
1952
+ // at stays where it is: the block of text at the top of the viewport is noted
1953
+ // before the swap and put back after it. Read in the bubble's stream, it is
1954
+ // found again in the reply; in a row above or below the bubble, in that row.
1955
+ export function captureLiveReplyReadingPosition() {
1956
+ const el = getMessagesElement();
1957
+ if (!el) return null;
1958
+ const bubble = document.getElementById('thinking-indicator');
1959
+ return {
1960
+ scrollTop: el.scrollTop,
1961
+ block: captureReadingBlock(el),
1962
+ // Below the bubble the pixel position is the wrong fallback: everything
1963
+ // the reader sees moves up or down with the swap, and scrollTop with it.
1964
+ belowBubble: !!bubble && bubble.getBoundingClientRect().bottom <= el.getBoundingClientRect().top,
1965
+ };
1966
+ }
1967
+
1968
+ export function restoreLiveReplyReadingPosition(position) {
1969
+ const el = getMessagesElement();
1970
+ if (!el || !position || isChatInteractionHeld()) return;
1971
+ if (restoreReadingBlock(el, position.block)) return;
1972
+ if (!position.belowBubble) el.scrollTop = position.scrollTop;
1973
+ }
1974
+
1975
+ // A rebuild of the transcript on the page that nobody asked to scroll: the
1976
+ // reader keeps the text they are at, or failing that the pixel position.
1977
+ function renderMessagesInPlace(msgs, meta) {
1978
+ const el = getMessagesElement();
1979
+ const reading = el ? { scrollTop: el.scrollTop, block: captureReadingBlock(el) } : null;
1980
+ const rendered = renderMessages(msgs, false, meta);
1981
+ if (el && reading && rendered && !restoreReadingBlock(el, reading.block)) el.scrollTop = reading.scrollTop;
1982
+ return rendered;
1983
+ }
1984
+
1988
1985
  // The live turn's compaction line: the bubble stamps the turn's compaction
1989
1986
  // for the separator pass — "Compacting context…" while the last compaction
1990
1987
  // entry is a start without an end, the final label once the boundary is in —
@@ -2749,11 +2746,16 @@ export function applyRelayStreamEvent({
2749
2746
 
2750
2747
  export function flushDeferredMessageRender() {
2751
2748
  if (!deferredMessageRender) return;
2752
- const { msgs, scroll, meta } = deferredMessageRender;
2749
+ const { msgs, meta } = deferredMessageRender;
2753
2750
  deferredMessageRender = null;
2754
2751
  const deferredConversationId = String(meta?.conversationId || '').trim();
2755
2752
  if (deferredConversationId && deferredConversationId !== String(currentConvId || '').trim()) return;
2756
- renderMessages(msgs, scroll, meta);
2753
+ // Whoever asked for this render has long returned, and with it whatever
2754
+ // would have put the reader back; and where the reader was when it was
2755
+ // held back is not where they are now. Decided as it runs: at the end it
2756
+ // follows, anywhere else it leaves them at the text they read.
2757
+ if (followsTranscriptEnd()) renderMessages(msgs, true, meta);
2758
+ else renderMessagesInPlace(msgs, meta);
2757
2759
  }
2758
2760
 
2759
2761
  // Blacklists the id from further live relay_stream frames. Kept separate from
@@ -3077,6 +3079,7 @@ export function replaceRenderedMessage(msg, msgId) {
3077
3079
  }
3078
3080
 
3079
3081
  async function resendStoppedSteer(conversationId, markerId) {
3082
+ const send = beginOwnSend();
3080
3083
  const conversationKey = String(conversationId || '').trim();
3081
3084
  const id = String(markerId || '').trim();
3082
3085
  if (!conversationKey || !id || stoppedSteerResends.has(id)) return;
@@ -3128,7 +3131,7 @@ async function resendStoppedSteer(conversationId, markerId) {
3128
3131
  mode: selectedMode,
3129
3132
  timestamp: new Date().toISOString(),
3130
3133
  attachments,
3131
- }, true, clientMessageId, true);
3134
+ }, ownSendFollows(send), clientMessageId, true);
3132
3135
  bubbleAppended = true;
3133
3136
  }
3134
3137
  const r = await sendMessageApi({
@@ -3172,7 +3175,7 @@ async function resendStoppedSteer(conversationId, markerId) {
3172
3175
  showTransientRelayNotice(`Resent without ${dropped} attachment${dropped === 1 ? '' : 's'} that ${dropped === 1 ? 'is' : 'are'} no longer available.`, 7000);
3173
3176
  }
3174
3177
  if (cliOnline && isCurrentConversation(conversationKey) && !getActiveTurnForConversation(conversationKey)?.messageId) {
3175
- showThinking(r.messageId || null);
3178
+ showThinking(r.messageId || null, ownSendFollows(send));
3176
3179
  }
3177
3180
  } catch (error) {
3178
3181
  if (!hasSendEchoArrived(clientMessageId)) {
@@ -3341,6 +3344,35 @@ export function syncRenderedPinState(pinnedIds) {
3341
3344
  }
3342
3345
  }
3343
3346
 
3347
+ const HIDDEN_LABEL_HTML = '<span class="msg-hidden-label">Hidden from shared viewers</span>';
3348
+
3349
+ function shareVisibilityButtonTitle(hidden) {
3350
+ return hidden ? 'Shows this message in shared conversations' : 'Hides this message from shared conversations';
3351
+ }
3352
+
3353
+ // The hidden list changed (this page, another device): bring the bubbles on
3354
+ // the page in line without rebuilding them. A button whose own round trip is
3355
+ // still out keeps its "Hiding…" until the answer.
3356
+ export function syncRenderedShareVisibility(hiddenIds) {
3357
+ const el = getMessagesElement();
3358
+ if (!el) return;
3359
+ const ids = hiddenIds instanceof Set ? hiddenIds : new Set(hiddenIds || []);
3360
+ for (const button of el.querySelectorAll('.msg-share-visibility-btn')) {
3361
+ const messageId = String(button.dataset.messageId || '').trim();
3362
+ if (shareVisibilityInFlight.has(messageId)) continue;
3363
+ const hidden = ids.has(messageId);
3364
+ if ((button.dataset.hiddenFromShares === 'true') === hidden) continue;
3365
+ button.dataset.hiddenFromShares = hidden ? 'true' : 'false';
3366
+ button.disabled = false;
3367
+ button.textContent = hidden ? 'Unhide' : 'Hide';
3368
+ button.title = shareVisibilityButtonTitle(hidden);
3369
+ const sibling = button.previousElementSibling;
3370
+ const label = sibling?.classList?.contains('msg-hidden-label') ? sibling : null;
3371
+ if (hidden && !label) button.insertAdjacentHTML('beforebegin', HIDDEN_LABEL_HTML);
3372
+ if (!hidden && label) label.remove();
3373
+ }
3374
+ }
3375
+
3344
3376
  async function toggleMessageShareVisibility(conversationId, messageId, hiddenFromShares) {
3345
3377
  const conversationKey = String(conversationId || '').trim();
3346
3378
  const targetMessageId = String(messageId || '').trim();
@@ -3359,13 +3391,17 @@ async function toggleMessageShareVisibility(conversationId, messageId, hiddenFro
3359
3391
  button.disabled = false;
3360
3392
  button.textContent = hiddenFromShares ? 'Unhide' : 'Hide';
3361
3393
  }
3362
- showTransientRelayNotice('Could not update shared-message visibility.');
3394
+ showTransientRelayNotice(String(result?.error || '').trim() || 'Could not update shared-message visibility.');
3363
3395
  return;
3364
3396
  }
3365
3397
 
3366
3398
  showTransientRelayNotice(result.hiddenFromShares
3367
3399
  ? 'Message hidden from shared viewers.'
3368
3400
  : 'Message visible to shared viewers.');
3401
+ // Stored under its own conversation: the 🙈 count, even after a switch.
3402
+ if ('hiddenMessages' in result) {
3403
+ setConversationHiddenMessages(conversationKey, result.hiddenMessages, result.hiddenMessagesRevision);
3404
+ }
3369
3405
  // The reload below only refreshes the open view; if the user switched
3370
3406
  // conversations while the toggle round trip was in flight, rendering it
3371
3407
  // would replace the new conversation's messages with this one's.
@@ -3375,7 +3411,7 @@ async function toggleMessageShareVisibility(conversationId, messageId, hiddenFro
3375
3411
  });
3376
3412
  if (String(currentConvId || '').trim() !== conversationKey) return;
3377
3413
  if (response?.messages) {
3378
- renderMessages(response.messages, false, response);
3414
+ renderMessagesInPlace(response.messages, response);
3379
3415
  }
3380
3416
  }
3381
3417
 
@@ -3545,8 +3581,8 @@ export function appendMessage(msg, scroll = true, msgId = null, force = false, i
3545
3581
  const node = createMessageNode(msg, msgId, force);
3546
3582
  const isNewNode = !!node && !node.parentNode;
3547
3583
  // 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.
3584
+ // failed turn's note) is placed under that prompt, and an agent prompt's
3585
+ // row reads its status from it.
3550
3586
  const exchangeRow = isNewNode ? node[AGENT_EXCHANGE_ROW_KEY] : null;
3551
3587
  const anchorId = String(insertAfterId || '').trim();
3552
3588
  if (exchangeRow?.role === 'assistant' && !exchangeRow.sourceMessageId && anchorId && anchorId !== String(msgId || '').trim()) {
@@ -3947,7 +3983,7 @@ export function focusConversationMessageById(messageId, { behavior = 'smooth', b
3947
3983
  if (!id) return false;
3948
3984
  const el = getMessagesElement();
3949
3985
  if (!el) return false;
3950
- // A message in a folded agent exchange is shown before it is scrolled to.
3986
+ // A folded agent prompt is opened before it is scrolled to.
3951
3987
  revealAgentExchangeMessage(id);
3952
3988
  const target = el.querySelector(`.msg[data-message-id="${CSS.escape(id)}"]`);
3953
3989
  if (!target) return false;
@@ -3990,6 +4026,9 @@ export function compactCurrentConversation() {
3990
4026
  let unknownCommandWarned = null;
3991
4027
 
3992
4028
  export async function sendMessage() {
4029
+ // The reader's own send, as of now: what follows it scrolls to the end
4030
+ // only while they have not scrolled since.
4031
+ const send = beginOwnSend();
3993
4032
  const input = document.getElementById('msg-input');
3994
4033
  const originalComposerText = String(input?.value || '');
3995
4034
  const text = originalComposerText.trim();
@@ -4030,7 +4069,7 @@ export async function sendMessage() {
4030
4069
  releaseComposerFocusAfterSend(input);
4031
4070
  void flushConversationDraft(currentConvId);
4032
4071
  compactCurrentConversation();
4033
- scrollBottomAfterSend();
4072
+ scrollAfterOwnSend(send);
4034
4073
  return;
4035
4074
  }
4036
4075
 
@@ -4107,8 +4146,10 @@ export async function sendMessage() {
4107
4146
  input.value = '';
4108
4147
  autoResize(input);
4109
4148
  releaseComposerFocusAfterSend(input);
4110
- appendMessage({ role: 'user', text, model: selectedModel, mode: selectedMode, timestamp: msgTimestamp, attachments }, true, clientMessageId, true);
4111
- scrollBottomAfterSend();
4149
+ // Asked before the bubble is on the page.
4150
+ const follow = ownSendFollows(send);
4151
+ appendMessage({ role: 'user', text, model: selectedModel, mode: selectedMode, timestamp: msgTimestamp, attachments }, follow, clientMessageId, true);
4152
+ scrollAfterOwnSend(send);
4112
4153
  }
4113
4154
 
4114
4155
  const body = {
@@ -4196,7 +4237,8 @@ export async function sendMessage() {
4196
4237
  await window.openConversation?.(r.compactedConversationId);
4197
4238
  removeComposerAttachments(draftAttachments);
4198
4239
  if (!mobileSend) input.focus();
4199
- scrollBottomAfterSend();
4240
+ // The send goes on in the conversation the compaction opened.
4241
+ scrollAfterOwnSend({ ...send, conversationId: String(r.compactedConversationId || '').trim() });
4200
4242
  }
4201
4243
  return;
4202
4244
  }
@@ -4217,6 +4259,7 @@ export async function sendMessage() {
4217
4259
  if (adoptNewConversation) {
4218
4260
  setCurrentConv(r.conversationId);
4219
4261
  composerConversationId = r.conversationId;
4262
+ nameOwnSendConversation(send, r.conversationId);
4220
4263
  }
4221
4264
  conversations[r.conversationId] = {
4222
4265
  id: r.conversationId,
@@ -4263,7 +4306,7 @@ export async function sendMessage() {
4263
4306
  // pinned to the newest message. Re-pin now rather than after the draft
4264
4307
  // save's round trip below, or the new bubble sits under the composer
4265
4308
  // for as long as that request takes.
4266
- if (viewingSendConversation()) scrollBottom();
4309
+ if (viewingSendConversation() && ownSendFollows(send)) scrollBottom();
4267
4310
  const persistedConversationId = String(r.conversationId || targetConversationId || '').trim();
4268
4311
  if (persistedConversationId) {
4269
4312
  restorableDraftByConversation.delete(persistedConversationId);
@@ -4295,7 +4338,7 @@ export async function sendMessage() {
4295
4338
  // A turn already running here owns the live bubble; adopting the
4296
4339
  // just-queued id would re-anchor it under the newest user message.
4297
4340
  const activeTurn = getActiveTurnForConversation(composerConversationId);
4298
- if (!activeTurn?.messageId) showThinking(r.messageId || null);
4341
+ if (!activeTurn?.messageId) showThinking(r.messageId || null, ownSendFollows(send));
4299
4342
  }
4300
4343
 
4301
4344
  // Only what this message took leaves the composer: attachments added (or
@@ -4304,7 +4347,7 @@ export async function sendMessage() {
4304
4347
  if (viewingSendConversation()) {
4305
4348
  removeComposerAttachments(draftAttachments);
4306
4349
  if (!mobileSend) input.focus();
4307
- scrollBottomAfterSend();
4350
+ scrollAfterOwnSend(send);
4308
4351
  }
4309
4352
  } catch (e) {
4310
4353
  const sendLanded = hasSendEchoArrived(clientMessageId);
@@ -608,7 +608,7 @@ async function submitChangeCwd(launchAfterChange = false) {
608
608
  ? `Next launch CWD saved as ${updatedPath}. The running CLI keeps its current CWD.`
609
609
  : `Next launch CWD saved as ${updatedPath}.`);
610
610
  } catch (error) {
611
- alert(error?.message || 'Failed to update the launch CWD');
611
+ alert(error?.message ? `Failed to update the launch CWD: ${error.message}` : 'Failed to update the launch CWD');
612
612
  } finally {
613
613
  changeCwdInFlight = false;
614
614
  setSummaryModalLoading(false);