@oar-sh/oar 0.9.20 → 0.9.22

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 (131) hide show
  1. package/CHANGELOG.md +325 -0
  2. package/README.md +13 -13
  3. package/bin/oar.js +2 -2
  4. package/package.json +1 -1
  5. package/server/claude-cloud-worker/claude-cloud-session-process.mjs +231 -1
  6. package/server/claude-cloud-worker/claude-cloud-session-worker.mjs +31 -2
  7. package/server/claude-cloud-worker/claude-cloud-worker-steering.mjs +34 -0
  8. package/server/claude-worker/claude-sdk-adapter.mjs +149 -4
  9. package/server/claude-worker/claude-session-process.mjs +16 -4
  10. package/server/claude-worker/claude-session-worker-main.mjs +18 -2
  11. package/server/copilot-worker/copilot-sdk-session-process.mjs +75 -1
  12. package/server/copilot-worker/copilot-sdk-session-worker.mjs +18 -2
  13. package/server/cursor-worker/cursor-session-worker.mjs +21 -2
  14. package/server/cursor-worker/cursor-worker-steering.mjs +20 -0
  15. package/server/db-schema.mjs +19 -0
  16. package/server/grok-worker/grok-background-tasks.mjs +10 -0
  17. package/server/grok-worker/grok-session-worker.mjs +22 -2
  18. package/server/grok-worker/grok-turn-runner.mjs +4 -0
  19. package/server/grok-worker/grok-worker-steering.mjs +23 -0
  20. package/server/migrations/0037-relay-asks-queue-id.mjs +66 -0
  21. package/server/migrations/0038-turn-seq.mjs +92 -0
  22. package/server/public/app/about-ui.js +1 -1
  23. package/server/public/app/about-view.mjs +19 -0
  24. package/server/public/app/action-confirmations.js +1 -1
  25. package/server/public/app/activity-replay-state.mjs +160 -42
  26. package/server/public/app/api-client.js +11 -2
  27. package/server/public/app/ask-user-view.js +24 -9
  28. package/server/public/app/attachments-view.js +1 -1
  29. package/server/public/app/back-guard.mjs +31 -5
  30. package/server/public/app/bootstrap.js +618 -72
  31. package/server/public/app/chat-actions-menu-state.mjs +141 -4
  32. package/server/public/app/chat-header-meta.mjs +27 -0
  33. package/server/public/app/claude-billing-toggle-ui.js +2 -1
  34. package/server/public/app/claude-cloud-conversation-ui.js +2 -2
  35. package/server/public/app/composer-settings-label.mjs +90 -0
  36. package/server/public/app/composer-settings-ui.js +442 -0
  37. package/server/public/app/context-menu.mjs +36 -5
  38. package/server/public/app/context-usage-view.mjs +68 -0
  39. package/server/public/app/conversation-deep-link.mjs +15 -11
  40. package/server/public/app/conversation-group-drag.mjs +12 -2
  41. package/server/public/app/conversation-group-order.mjs +9 -2
  42. package/server/public/app/conversation-group-state.mjs +47 -10
  43. package/server/public/app/conversation-origin-ui.js +17 -1
  44. package/server/public/app/conversation-view.js +309 -49
  45. package/server/public/app/cwd-menu-placement.mjs +18 -6
  46. package/server/public/app/cwd-picker.js +36 -6
  47. package/server/public/app/emoji-view.js +1 -1
  48. package/server/public/app/font-scaling.js +26 -0
  49. package/server/public/app/header-priority-actions.mjs +238 -0
  50. package/server/public/app/held-details.mjs +202 -0
  51. package/server/public/app/hidden-messages-view.mjs +4 -0
  52. package/server/public/app/inbox-ui.js +88 -81
  53. package/server/public/app/inbox-view.mjs +35 -19
  54. package/server/public/app/journal-view.js +328 -58
  55. package/server/public/app/live-activity-cap.mjs +103 -0
  56. package/server/public/app/phone-landscape-ui.js +225 -0
  57. package/server/public/app/phone-landscape.mjs +99 -0
  58. package/server/public/app/phone-typing-state.mjs +104 -0
  59. package/server/public/app/pinned-messages-view.mjs +5 -0
  60. package/server/public/app/push-settings.js +2 -2
  61. package/server/public/app/pwa-install.js +24 -3
  62. package/server/public/app/questions-panel-ui.js +634 -0
  63. package/server/public/app/questions-stub-view.mjs +40 -0
  64. package/server/public/app/questions-view.mjs +89 -0
  65. package/server/public/app/relay-board-view.js +12 -2
  66. package/server/public/app/relay-event-subscriptions-ui.js +71 -22
  67. package/server/public/app/relay-event-view.mjs +38 -0
  68. package/server/public/app/relay-restart-ui.js +2 -2
  69. package/server/public/app/relay-schedule-view.mjs +32 -6
  70. package/server/public/app/relay-schedules-ui.js +69 -20
  71. package/server/public/app/relay-thoughts.mjs +15 -1
  72. package/server/public/app/remote-relay-origin-view.mjs +41 -23
  73. package/server/public/app/session-history-ui.js +251 -0
  74. package/server/public/app/session-history.mjs +599 -0
  75. package/server/public/app/settings-modal.js +67 -6
  76. package/server/public/app/settings-tabs.js +12 -0
  77. package/server/public/app/share-state.js +1 -1
  78. package/server/public/app/shared-conversations-settings.js +2 -2
  79. package/server/public/app/socket-handlers.js +47 -13
  80. package/server/public/app/status-view.mjs +18 -2
  81. package/server/public/app/store.js +87 -16
  82. package/server/public/app/stream-view-preference.mjs +60 -0
  83. package/server/public/app/subagent-fold.mjs +6 -3
  84. package/server/public/app/subagent-timeline-view.mjs +12 -5
  85. package/server/public/app/subagent-viewer.mjs +35 -8
  86. package/server/public/app/tab-strip-fade.mjs +101 -0
  87. package/server/public/app/tap-actions.js +66 -0
  88. package/server/public/app/thoughts-preference.mjs +79 -0
  89. package/server/public/app/turn-stream-view.mjs +124 -0
  90. package/server/public/app/usage-limit-ui.js +63 -2
  91. package/server/public/app/viewer-history.mjs +13 -2
  92. package/server/public/index.html +2603 -1767
  93. package/server/relay-tools.md +1 -0
  94. package/server/repositories/message-repository.mjs +33 -0
  95. package/server/repositories/question-repository.mjs +51 -7
  96. package/server/repositories/relay-turn-rows.mjs +56 -0
  97. package/server/repositories/session-repository.mjs +19 -0
  98. package/server/routes/ask-user-routes.mjs +34 -5
  99. package/server/routes/claude-cloud-routes.mjs +19 -0
  100. package/server/routes/messages-routes.mjs +92 -15
  101. package/server/routes/relay-ask-routes.mjs +15 -5
  102. package/server/routes/sessions-routes.mjs +117 -12
  103. package/server/server-runtime.mjs +367 -170
  104. package/server/services/about-service.mjs +22 -53
  105. package/server/services/claude-billing-swap-service.mjs +7 -20
  106. package/server/services/claude-cloud-session-service.mjs +47 -5
  107. package/server/services/cloudflared-tunnel-service.mjs +7 -0
  108. package/server/services/idle-timers.mjs +184 -0
  109. package/server/services/queue-wake-hook.mjs +145 -0
  110. package/server/services/queue-waker.mjs +127 -0
  111. package/server/services/relay-ask-service.mjs +64 -5
  112. package/server/services/relay-event-service.mjs +4 -1
  113. package/server/services/remote-relay-dispatcher.mjs +84 -18
  114. package/server/services/session-activity-tracker.mjs +1 -1
  115. package/server/services/session-history-refresh-service.mjs +4 -0
  116. package/server/services/session-launch-mutex-service.mjs +23 -0
  117. package/server/services/session-worker-registry-service.mjs +36 -0
  118. package/server/services/session-worker-supervisor-service.mjs +36 -0
  119. package/server/services/session-worker-websocket-service.mjs +183 -31
  120. package/server/services/ssh-tunnel-manager-service.mjs +7 -0
  121. package/server/services/worker-recycle-service.mjs +772 -0
  122. package/server/services/worker-replacement.mjs +197 -0
  123. package/shared/ask-user-bridge.mjs +9 -6
  124. package/shared/build-id.mjs +128 -0
  125. package/shared/relay-asks.mjs +10 -0
  126. package/shared/relay-events.mjs +1 -0
  127. package/shared/worker-runtime/heartbeat.mjs +31 -0
  128. package/shared/worker-runtime/simple-steering-state.mjs +38 -0
  129. package/shared/worker-runtime/worker-keepalive.mjs +108 -0
  130. package/shared/worker-runtime/worker-websocket-link.mjs +44 -10
  131. package/shared/worker-runtime/working-directory.mjs +1 -1
@@ -94,6 +94,11 @@ import {
94
94
  } from './composer-attachment-cache.mjs';
95
95
  import { renderRelayQuestions } from './ask-user-view.js';
96
96
  import { renderRelayBoards } from './relay-board-view.js';
97
+ import { renderQuestionStubs } from './questions-panel-ui.js';
98
+ import { isThoughtsExpanded, rememberThoughtFoldChoice, thoughtFoldOpen } from './thoughts-preference.mjs';
99
+ import { liveActivityCap } from './live-activity-cap.mjs';
100
+ import { isStreamViewEnabled } from './stream-view-preference.mjs';
101
+ import { isProgressActivityText, renderTurnStreamMarkup, turnStreamEntries } from './turn-stream-view.mjs';
97
102
  import { getMessageThreadAnchor, sortConversationMessages } from './thread-order.mjs';
98
103
  import {
99
104
  normalizeStreamSeq,
@@ -102,11 +107,13 @@ import {
102
107
  computeNextRelayStreamState,
103
108
  } from './stream-state.mjs';
104
109
  import {
105
- capRelayActivityEntries,
110
+ capFinishedActivityEntries,
106
111
  compactBoundaryFromActivities,
112
+ isOmittedActivityMarker,
107
113
  isCompactBoundaryActivityEntry,
108
114
  mergeRelayActivityTexts,
109
115
  normalizeRelayActivityEntry,
116
+ normalizeTurnSeq,
110
117
  promotedCompactBoundaryEntry,
111
118
  relayActivityEntryText,
112
119
  visibleActivityEntries,
@@ -154,7 +161,8 @@ import { createHeldBackLiveRows, newerPageReachesEnd } from './transcript-island
154
161
  import { buildInFlightSnapshotKey } from './in-flight-snapshot.mjs';
155
162
  import { computeStablePrefixLength, planListPatch } from './streaming-dom-patch.mjs';
156
163
  import { prefixToolActivityEmoji } from './tool-activity-emoji.mjs';
157
- import { conversationPayloadCache } from './conversation-payload-cache.mjs';
164
+ import { conversationPayloadCache, windowPageInfo } from './conversation-payload-cache.mjs';
165
+ import { noteConversationShown } from './session-history-ui.js';
158
166
 
159
167
  const CONVERSATION_HISTORY_PAGE_SIZE = 20;
160
168
  const HISTORY_LOAD_MORE_ID = 'history-load-more';
@@ -1612,9 +1620,10 @@ function createMessageNode(msg, msgId = null, force = false) {
1612
1620
  const content = renderMarkdownPreview(msg.text || '', false);
1613
1621
  const attachments = Array.isArray(msg.attachments) ? msg.attachments : [];
1614
1622
  // capRelayActivityEntries, not slice: a boundary past the cap would take
1615
- // the break row with it.
1623
+ // the break row with it. The relay's cap of a finished turn (newest rows
1624
+ // per thread), so an older relay's longer list reads the same.
1616
1625
  const activities = Array.isArray(msg.activities)
1617
- ? capRelayActivityEntries(msg.activities.filter(Boolean), 48)
1626
+ ? capFinishedActivityEntries(msg.activities.filter(Boolean))
1618
1627
  : [];
1619
1628
  // A compaction that happened during this turn is rendered as a full-width
1620
1629
  // break row immediately above this message (syncTranscriptSeparators reads
@@ -1633,8 +1642,27 @@ function createMessageNode(msg, msgId = null, force = false) {
1633
1642
  const subagentRuns = Array.isArray(msg.subagentRuns) ? msg.subagentRuns.filter(Boolean) : [];
1634
1643
  const mainActivities = activities.filter((item) => !(normalizeRelayActivityEntry(item)?.subagentRunId));
1635
1644
  const mainThoughts = thoughts.filter((t) => !t?.subagentRunId);
1636
- const activityHtml = mainActivities.length ? renderActivityMarkup(mainActivities, promotedBoundaryEntry) : '';
1637
- const thoughtsHtml = mainThoughts.length ? renderThoughtsMarkup(mainThoughts) : '';
1645
+ // Stream view: the turn's thoughts, tool lines and reply as one list in
1646
+ // the order they happened, when the turn's rows carry their order (null:
1647
+ // the grouped layout below, unchanged). Progress lines ('● ') stay where
1648
+ // the grouped layout puts them.
1649
+ const streamEntries = msg.role === 'assistant' && isStreamViewEnabled()
1650
+ ? turnStreamEntries({
1651
+ thoughts: mainThoughts,
1652
+ activities: visibleActivityEntries(mainActivities, promotedBoundaryEntry),
1653
+ text: msg.text || '',
1654
+ })
1655
+ : null;
1656
+ const activityHtml = streamEntries
1657
+ ? renderActivityMarkup(mainActivities.filter((item) => isProgressActivityText(relayActivityEntryText(item))), promotedBoundaryEntry)
1658
+ : (mainActivities.length ? renderActivityMarkup(mainActivities, promotedBoundaryEntry) : '');
1659
+ const thoughtsHtml = !streamEntries && mainThoughts.length ? renderThoughtsMarkup(mainThoughts) : '';
1660
+ const replyHtml = streamEntries
1661
+ ? renderTurnStreamMarkup(streamEntries, {
1662
+ renderMarkdown: (text) => renderMarkdownPreview(text, false),
1663
+ decorate: decorateActivityText,
1664
+ })
1665
+ : content;
1638
1666
  // One row per sub-agent the turn spawned, as in the composer's task panel;
1639
1667
  // its title opens the agent's whole record. The store's copy wins: status
1640
1668
  // events keep it current after the bubble was built.
@@ -1719,7 +1747,7 @@ function createMessageNode(msg, msgId = null, force = false) {
1719
1747
  : '';
1720
1748
 
1721
1749
  div.innerHTML = `
1722
- <div class="${bubbleClass}">${shareVisibilityActionHtml}${thoughtsHtml}${originBadgeHtml}${content}${relayErrorCtaHtml}${attachmentHtml}${activityHtml}${subagentHtml}${workflowRunsHtml}${previewCardsHtml}${userBubbleActionsHtml}${stoppedSteerActionsHtml}${workerLaunchActionsHtml}</div>
1750
+ <div class="${bubbleClass}">${shareVisibilityActionHtml}${thoughtsHtml}${originBadgeHtml}${replyHtml}${relayErrorCtaHtml}${attachmentHtml}${activityHtml}${subagentHtml}${workflowRunsHtml}${previewCardsHtml}${userBubbleActionsHtml}${stoppedSteerActionsHtml}${workerLaunchActionsHtml}</div>
1723
1751
  <div class="msg-label">${label}${modelTag}${reasoningTag}${modeTag}${autoTag}${continuationTag}${crossProviderTag}${usageTurnTag}${usageRemainingTag}${usageStaleTag} · ${fmtDate(msg.timestamp)}</div>`;
1724
1752
 
1725
1753
  const bubble = div.querySelector('.msg-bubble');
@@ -1914,10 +1942,12 @@ export function renderActivityMarkup(activities, promotedBoundaryEntry = undefin
1914
1942
  const progressHtml = progress.length
1915
1943
  ? `<div class="msg-activity-list">${progress.map((item) => `<div class="msg-activity-item">${escHtml(decorateActivityText(relayActivityEntryText(item)))}</div>`).join('')}</div>`
1916
1944
  : '';
1945
+ // The "… N earlier tool lines omitted" row is listed but is no tool line.
1946
+ const toolCount = tools.filter((item) => !isOmittedActivityMarker(item)).length;
1917
1947
  const toolsHtml = tools.length
1918
1948
  ? `
1919
1949
  <details class="msg-activity">
1920
- <summary>🔧 Tool activity (${tools.length})</summary>
1950
+ <summary>🔧 Tool activity (${toolCount})</summary>
1921
1951
  <div class="msg-activity-list">${tools.map((item) => `<div class="msg-activity-item">${escHtml(decorateActivityText(relayActivityEntryText(item)))}</div>`).join('')}</div>
1922
1952
  </details>`
1923
1953
  : '';
@@ -2011,6 +2041,7 @@ export function showThinking(messageId = null, autoScroll = true) {
2011
2041
  <summary>💭 Thoughts</summary>
2012
2042
  <div class="thinking-thoughts-list"></div>
2013
2043
  </details>
2044
+ ${isStreamViewEnabled() ? '<div id="thinking-stream-list" class="thinking-stream-list"></div>' : ''}
2014
2045
  <div id="thinking-stream" class="thinking-stream" hidden></div>
2015
2046
  <div class="thinking-compacting-note" hidden>Compacting the conversation…</div>
2016
2047
  <div class="dots"><span></span><span></span><span></span></div>
@@ -2021,8 +2052,10 @@ export function showThinking(messageId = null, autoScroll = true) {
2021
2052
  syncThinkingCompaction(div);
2022
2053
  renderThinkingThoughts();
2023
2054
  renderThinkingStream();
2024
- // A board the turn has posted already lives in its live bubble.
2055
+ // A board the turn has posted already lives in its live bubble, and so
2056
+ // does the 📓 stub of an ask it made.
2025
2057
  renderRelayBoards();
2058
+ renderQuestionStubs();
2026
2059
  observeThinkingBubble(div);
2027
2060
  if (getThinkingFollowMode()) applyThinkingFollow();
2028
2061
  else if (autoScroll) scrollBottom();
@@ -2157,7 +2190,7 @@ export function collapseThinkingThoughts() {
2157
2190
  if (!(panel instanceof HTMLDetailsElement)) return;
2158
2191
  panel.open = false;
2159
2192
  panel.querySelectorAll('.thinking-thought').forEach((row) => {
2160
- if (row instanceof HTMLDetailsElement) row.open = false;
2193
+ if (row instanceof HTMLDetailsElement) showLiveThoughtRow(row, false);
2161
2194
  });
2162
2195
  }
2163
2196
 
@@ -2251,6 +2284,7 @@ export function renderThinkingActivities() {
2251
2284
  // clear-and-replay (or the append path's last-row-only dedupe) would either
2252
2285
  // wipe selections or duplicate rows on every changed payload.
2253
2286
  const mainExpected = [];
2287
+ const mainEntries = [];
2254
2288
  const bySubagentRun = new Map();
2255
2289
  for (const item of items) {
2256
2290
  const entry = normalizeRelayActivityEntry(item);
@@ -2260,15 +2294,22 @@ export function renderThinkingActivities() {
2260
2294
  // would otherwise flip presentation the moment the turn lands.
2261
2295
  if (isCompactBoundaryActivityEntry(entry)) continue;
2262
2296
  const decorated = decorateActivityText(entry.text);
2297
+ // A numbered row is its own line even when the same tool line came just
2298
+ // before (the same call twice); an unnumbered repeat is one line.
2299
+ const numbered = entry.turnSeq !== undefined;
2263
2300
  if (entry.subagentRunId) {
2264
2301
  const list = bySubagentRun.get(entry.subagentRunId) || [];
2265
- if (list[list.length - 1] !== decorated) list.push(decorated);
2302
+ if (numbered || list[list.length - 1] !== decorated) list.push(decorated);
2266
2303
  bySubagentRun.set(entry.subagentRunId, list);
2267
- } else if (mainExpected[mainExpected.length - 1] !== decorated) {
2304
+ } else if (numbered || mainExpected[mainExpected.length - 1] !== decorated) {
2268
2305
  mainExpected.push(decorated);
2306
+ mainEntries.push({ text: decorated, turnSeq: entry.turnSeq ?? null });
2269
2307
  }
2270
2308
  }
2271
- if (!selectionIntersectsNode(box)) {
2309
+ const streamList = liveStreamList();
2310
+ if (streamList) {
2311
+ if (!selectionIntersectsNode(streamList)) patchStreamListActivities(streamList, mainEntries);
2312
+ } else if (!selectionIntersectsNode(box)) {
2272
2313
  patchActivityList(box, mainExpected, 'thinking-activity-item');
2273
2314
  }
2274
2315
  syncThinkingCompaction();
@@ -2296,7 +2337,9 @@ export function restoreInFlightThinking(inFlight, autoScroll = true) {
2296
2337
  // live poll would otherwise churn the DOM (and the user's selection) with
2297
2338
  // identical content. The indicator must belong to this turn's message —
2298
2339
  // skipping on a foreign bubble would let a misplaced one stick around.
2299
- const snapshotKey = buildInFlightSnapshotKey(inFlight);
2340
+ // The cap is part of it: a changed "Tool lines shown" refills or trims the
2341
+ // bubble on the next poll even when the payload is the same.
2342
+ const snapshotKey = `${liveActivityCap()}|${buildInFlightSnapshotKey(inFlight)}`;
2300
2343
  const liveIndicator = document.getElementById('thinking-indicator');
2301
2344
  if (snapshotKey === lastInFlightSnapshotKey
2302
2345
  && liveIndicator
@@ -2312,6 +2355,7 @@ export function restoreInFlightThinking(inFlight, autoScroll = true) {
2312
2355
  const activities = mergeRelayActivityTexts(
2313
2356
  relayActivities.get(messageId) || [],
2314
2357
  Array.isArray(inFlight.activities) ? inFlight.activities : [],
2358
+ liveActivityCap(),
2315
2359
  );
2316
2360
  relayActivities.set(messageId, activities);
2317
2361
  const inFlightThoughts = Array.isArray(inFlight.thoughts) ? inFlight.thoughts : [];
@@ -2319,7 +2363,15 @@ export function restoreInFlightThinking(inFlight, autoScroll = true) {
2319
2363
  const thoughtMap = relayThoughts.get(messageId) || new Map();
2320
2364
  for (const entry of inFlightThoughts) {
2321
2365
  const key = String(entry?.reasoningId || `seq-${entry?.seq || thoughtMap.size}`);
2322
- thoughtMap.set(key, { reasoningId: key, text: String(entry?.text || ''), done: !!entry?.done, subagentRunId: entry?.subagentRunId || null });
2366
+ // A thought keeps the place of its first chunk.
2367
+ const turnSeq = thoughtMap.get(key)?.turnSeq ?? normalizeTurnSeq(entry?.turnSeq);
2368
+ thoughtMap.set(key, {
2369
+ reasoningId: key,
2370
+ text: String(entry?.text || ''),
2371
+ done: !!entry?.done,
2372
+ subagentRunId: entry?.subagentRunId || null,
2373
+ ...(turnSeq != null ? { turnSeq } : {}),
2374
+ });
2323
2375
  }
2324
2376
  relayThoughts.set(messageId, thoughtMap);
2325
2377
  }
@@ -2404,6 +2456,7 @@ export function appendThinkingActivity(item, subagentRunId = null, autoScroll =
2404
2456
  row.className = 'subagent-activity-item';
2405
2457
  row.textContent = decorated;
2406
2458
  activityBox.appendChild(row);
2459
+ trimLiveActivityRows(activityBox, 'subagent-activity-item');
2407
2460
  refreshSubagentSummary(subagentBubble);
2408
2461
  }
2409
2462
  }
@@ -2414,18 +2467,105 @@ export function appendThinkingActivity(item, subagentRunId = null, autoScroll =
2414
2467
  return;
2415
2468
  }
2416
2469
 
2417
- const box = document.getElementById('thinking-activity');
2470
+ // Stream view: the main agent's tool lines share one list with its
2471
+ // thoughts, in the turn's order.
2472
+ const streamList = liveStreamList();
2473
+ const box = streamList || document.getElementById('thinking-activity');
2418
2474
  if (!box) return;
2419
- const last = box.lastElementChild?.textContent || '';
2420
- if (last === decorated) return;
2475
+ const numbered = entry.turnSeq !== undefined;
2476
+ if (numbered) {
2477
+ // A numbered line is known by its number: the same call twice is two.
2478
+ if (box.querySelector(`.thinking-activity-item[data-turn-seq="${entry.turnSeq}"]`)) return;
2479
+ } else if (streamList) {
2480
+ const rows = streamList.querySelectorAll(':scope > .thinking-activity-item');
2481
+ if ((rows[rows.length - 1]?.textContent || '') === decorated) return;
2482
+ } else if ((box.lastElementChild?.textContent || '') === decorated) {
2483
+ return;
2484
+ }
2421
2485
  const row = document.createElement('div');
2422
2486
  row.className = 'thinking-activity-item';
2423
- if (subagentRunId) row.dataset.subagentRunId = subagentRunId;
2487
+ if (numbered) row.dataset.turnSeq = String(entry.turnSeq);
2424
2488
  row.textContent = decorated;
2425
- box.appendChild(row);
2489
+ if (streamList) placeByTurnSeq(streamList, row);
2490
+ else box.appendChild(row);
2491
+ trimLiveActivityRows(box, 'thinking-activity-item');
2426
2492
  if (autoScroll) scrollBottom();
2427
2493
  }
2428
2494
 
2495
+ // The live bubble's single list of thoughts and tool lines, while the stream
2496
+ // view is on (the bubble is rebuilt when it is switched).
2497
+ function liveStreamList() {
2498
+ return isStreamViewEnabled() ? document.getElementById('thinking-stream-list') : null;
2499
+ }
2500
+
2501
+ // The stream list keeps its rows in the turn's order: a numbered row goes
2502
+ // before the first row with a higher number, an unnumbered one (an older
2503
+ // relay, a thought not yet numbered) at the end.
2504
+ function placeByTurnSeq(list, row) {
2505
+ const seq = Number(row.dataset.turnSeq);
2506
+ if (!list.id || list.id !== 'thinking-stream-list' || !Number.isFinite(seq)) {
2507
+ if (row.parentNode !== list) list.appendChild(row);
2508
+ return;
2509
+ }
2510
+ for (const child of list.children) {
2511
+ if (child === row) continue;
2512
+ const childSeq = Number(child.dataset.turnSeq);
2513
+ if (Number.isFinite(childSeq) && childSeq > seq) {
2514
+ if (row.nextSibling !== child) list.insertBefore(row, child);
2515
+ return;
2516
+ }
2517
+ }
2518
+ if (row.parentNode !== list || list.lastElementChild !== row) list.appendChild(row);
2519
+ }
2520
+
2521
+ /**
2522
+ * The poll replay of the stream list's tool lines: `expected` is the main
2523
+ * agent's lines as the turn holds them now ({ text, turnSeq }, already
2524
+ * capped). Rows no longer expected go, missing ones are placed by number;
2525
+ * thoughts are left where they are.
2526
+ */
2527
+ function patchStreamListActivities(list, expected) {
2528
+ const rows = [...list.querySelectorAll(':scope > .thinking-activity-item')];
2529
+ if (expected.every((entry) => entry.turnSeq !== null)) {
2530
+ const wanted = new Map(expected.map((entry) => [String(entry.turnSeq), entry]));
2531
+ for (const row of rows) {
2532
+ if (!wanted.has(row.dataset.turnSeq || '')) row.remove();
2533
+ }
2534
+ for (const entry of expected) {
2535
+ const key = String(entry.turnSeq);
2536
+ let row = list.querySelector(`:scope > .thinking-activity-item[data-turn-seq="${key}"]`);
2537
+ if (!row) {
2538
+ row = document.createElement('div');
2539
+ row.className = 'thinking-activity-item';
2540
+ row.dataset.turnSeq = key;
2541
+ }
2542
+ if (row.textContent !== entry.text) row.textContent = entry.text;
2543
+ placeByTurnSeq(list, row);
2544
+ }
2545
+ return;
2546
+ }
2547
+ // Unnumbered lines (an older relay): in arrival order after the rest.
2548
+ if (rows.length === expected.length && rows.every((row, index) => row.textContent === expected[index].text)) return;
2549
+ for (const row of rows) row.remove();
2550
+ for (const entry of expected) {
2551
+ const row = document.createElement('div');
2552
+ row.className = 'thinking-activity-item';
2553
+ if (entry.turnSeq !== null) row.dataset.turnSeq = String(entry.turnSeq);
2554
+ row.textContent = entry.text;
2555
+ list.appendChild(row);
2556
+ }
2557
+ }
2558
+
2559
+ // A line that arrives between two poll replays is appended at once; the
2560
+ // thread's oldest lines beyond the "Tool lines shown" setting go with it, as
2561
+ // the next replay would drop them (each box holds one thread's lines).
2562
+ function trimLiveActivityRows(box, className) {
2563
+ const cap = liveActivityCap();
2564
+ if (!box || !(cap > 0) || selectionIntersectsNode(box)) return;
2565
+ const rows = box.querySelectorAll(`:scope > .${className}`);
2566
+ for (let index = 0; index < rows.length - cap; index += 1) rows[index].remove();
2567
+ }
2568
+
2429
2569
  function getSubagentDisplayName(subagentRunId) {
2430
2570
  const entry = typeof getSubagentRun === 'function' ? getSubagentRun(subagentRunId) : null;
2431
2571
  if (entry?.displayName) return entry.displayName;
@@ -2567,7 +2707,11 @@ function ensureSubagentBubble(subagentRunId, depth = 0) {
2567
2707
  }
2568
2708
  }
2569
2709
  if (entry?.activities?.length) {
2570
- for (const item of entry.activities) {
2710
+ // The run's newest lines only, as many as the "Tool lines shown" setting
2711
+ // keeps per agent.
2712
+ const cap = liveActivityCap();
2713
+ const replayed = cap > 0 ? entry.activities.slice(-cap) : entry.activities;
2714
+ for (const item of replayed) {
2571
2715
  const text = typeof item === 'string' ? item : String(item?.text || '').trim();
2572
2716
  if (!text) continue;
2573
2717
  const decorated = decorateActivityText(text);
@@ -2594,6 +2738,7 @@ function refreshSubagentSummary(bubble) {
2594
2738
  const text = subagentFoldSummary({
2595
2739
  activities: Array.from(bubble.querySelectorAll(':scope > .subagent-activity > .subagent-activity-item'), (row) => row.textContent || ''),
2596
2740
  streamText: bubble.querySelector(':scope > .subagent-stream')?.textContent || '',
2741
+ cap: liveActivityCap(),
2597
2742
  });
2598
2743
  if (summary.textContent !== text) summary.textContent = text;
2599
2744
  }
@@ -2716,17 +2861,95 @@ function thoughtSummaryText(text) {
2716
2861
  return value.length > 80 ? `${value.slice(0, 80)}…` : value;
2717
2862
  }
2718
2863
 
2864
+ /**
2865
+ * A chunk only updates the row's state: whether it shows open was decided
2866
+ * when the row was made (the "Expand thoughts" setting or the reader's own
2867
+ * fold), so a thought folded by hand stays folded as more text streams in.
2868
+ */
2719
2869
  export function setLiveThinkingThoughtState(row, done = false) {
2720
2870
  if (!row) return;
2721
2871
  row.dataset.done = done ? '1' : '0';
2722
- // The temporary panel represents an active turn, so every thought remains visible
2723
- // until the completed assistant message replaces it with collapsed history.
2724
- row.open = true;
2725
2872
  }
2726
2873
 
2727
- export function appendThinkingThought(reasoningId, text, done = false, subagentRunId = null, autoScroll = true) {
2874
+ // What the reader chose by hand for a live thought ('open' | 'folded'), by
2875
+ // turn, sub-agent run and reasoning id. Kept outside the DOM: the live bubble
2876
+ // is rebuilt on a conversation switch and on a poll replay, and the choice
2877
+ // must survive it. Main-thread reasoning ids are positional within a turn
2878
+ // (the first thought of every turn has the same id), so the turn is part of
2879
+ // the key; a sub-agent run id is unique already and its rows are keyed
2880
+ // without a turn, so a row rebuilt from the store finds its choice.
2881
+ const liveThoughtFoldChoices = new Map();
2882
+
2883
+ /** The turn part of a live thought's key: the queue message, '' for a sub-agent row. */
2884
+ function liveThoughtTurnId(messageId, subagentRunId = null) {
2885
+ return subagentRunId ? '' : String(messageId || '');
2886
+ }
2887
+
2888
+ function liveThoughtFoldKey(turnId, reasoningId, subagentRunId = null) {
2889
+ return `${String(turnId || '')}\u0000${String(subagentRunId || '')}\u0000${String(reasoningId || '')}`;
2890
+ }
2891
+
2892
+ /** Open or folded for a live thought row being made (exported for tests). */
2893
+ export function liveThoughtStartsOpen(reasoningId, subagentRunId = null, turnId = '') {
2894
+ return thoughtFoldOpen({
2895
+ choice: liveThoughtFoldChoices.get(liveThoughtFoldKey(turnId, reasoningId, subagentRunId)) || null,
2896
+ expanded: isThoughtsExpanded(),
2897
+ });
2898
+ }
2899
+
2900
+ /**
2901
+ * Show a live thought row open or folded without that counting as the
2902
+ * reader's choice. `dataset.shownOpen` is what the page last set (or saw the
2903
+ * reader set); a toggle event that disagrees with it came from the reader.
2904
+ */
2905
+ export function showLiveThoughtRow(row, open) {
2906
+ if (!row) return;
2907
+ row.dataset.shownOpen = open ? '1' : '0';
2908
+ if (row.open !== !!open) row.open = !!open;
2909
+ }
2910
+
2911
+ /** The toggle listener of a live thought row (exported for tests). */
2912
+ export function noteLiveThoughtToggle(row) {
2913
+ if (!row?.dataset) return;
2914
+ const open = !!row.open;
2915
+ if (row.dataset.shownOpen === (open ? '1' : '0')) return;
2916
+ row.dataset.shownOpen = open ? '1' : '0';
2917
+ rememberThoughtFoldChoice(
2918
+ liveThoughtFoldChoices,
2919
+ liveThoughtFoldKey(row.dataset.turnId || '', row.dataset.reasoningId, row.dataset.subagentRunId || null),
2920
+ open,
2921
+ );
2922
+ }
2923
+
2924
+ /**
2925
+ * A live thought row, shown open or folded as the reader's choice or the
2926
+ * setting says. `turnId` comes from the caller (the frame's message id), never
2927
+ * from the live bubble's state, so a rebuilt row gets the same key.
2928
+ */
2929
+ export function createLiveThoughtRow(key, subagentRunId = null, turnId = '') {
2930
+ const row = document.createElement('details');
2931
+ row.className = 'thinking-thought';
2932
+ row.dataset.reasoningId = key;
2933
+ row.dataset.turnId = String(turnId || '');
2934
+ if (subagentRunId) row.dataset.subagentRunId = subagentRunId;
2935
+ showLiveThoughtRow(row, liveThoughtStartsOpen(key, subagentRunId, row.dataset.turnId));
2936
+ row.addEventListener('toggle', () => noteLiveThoughtToggle(row));
2937
+ const summary = document.createElement('summary');
2938
+ const body = document.createElement('div');
2939
+ body.className = 'thinking-thought-body';
2940
+ row.appendChild(summary);
2941
+ row.appendChild(body);
2942
+ return row;
2943
+ }
2944
+
2945
+ /**
2946
+ * Add or update a live thought. `messageId` is the turn the frame belongs to
2947
+ * (the key of the reader's fold choice for a main-thread thought).
2948
+ */
2949
+ export function appendThinkingThought(reasoningId, text, done = false, subagentRunId = null, autoScroll = true, messageId = null) {
2728
2950
  const key = String(reasoningId || 'reasoning');
2729
2951
  const value = String(text || '');
2952
+ const turnId = liveThoughtTurnId(messageId, subagentRunId);
2730
2953
 
2731
2954
  if (subagentRunId) {
2732
2955
  const subagentBubble = ensureSubagentBubble(subagentRunId);
@@ -2741,16 +2964,7 @@ export function appendThinkingThought(reasoningId, text, done = false, subagentR
2741
2964
  }
2742
2965
  let row = thoughtsBox.querySelector(`.thinking-thought[data-reasoning-id="${CSS.escape(key)}"]`);
2743
2966
  if (!row) {
2744
- row = document.createElement('details');
2745
- row.className = 'thinking-thought';
2746
- row.open = !done;
2747
- row.dataset.reasoningId = key;
2748
- row.dataset.subagentRunId = subagentRunId;
2749
- const summary = document.createElement('summary');
2750
- const body = document.createElement('div');
2751
- body.className = 'thinking-thought-body';
2752
- row.appendChild(summary);
2753
- row.appendChild(body);
2967
+ row = createLiveThoughtRow(key, subagentRunId, turnId);
2754
2968
  thoughtsBox.appendChild(row);
2755
2969
  }
2756
2970
  const summaryEl = row.querySelector('summary');
@@ -2765,22 +2979,23 @@ export function appendThinkingThought(reasoningId, text, done = false, subagentR
2765
2979
  return;
2766
2980
  }
2767
2981
 
2768
- const box = document.querySelector('#thinking-thoughts > .thinking-thoughts-list');
2982
+ // Stream view: the main agent's thoughts share one list with its tool
2983
+ // lines, in the turn's order (the number the store keeps for the thought).
2984
+ const streamList = liveStreamList();
2985
+ const box = streamList || document.querySelector('#thinking-thoughts > .thinking-thoughts-list');
2769
2986
  if (!box) return;
2770
2987
  let row = box.querySelector(`.thinking-thought[data-reasoning-id="${CSS.escape(key)}"]`);
2771
2988
  if (!row) {
2772
- row = document.createElement('details');
2773
- row.className = 'thinking-thought';
2774
- row.open = !done;
2775
- row.dataset.reasoningId = key;
2776
- if (subagentRunId) row.dataset.subagentRunId = subagentRunId;
2777
- const summary = document.createElement('summary');
2778
- const body = document.createElement('div');
2779
- body.className = 'thinking-thought-body';
2780
- row.appendChild(summary);
2781
- row.appendChild(body);
2989
+ row = createLiveThoughtRow(key, subagentRunId, turnId);
2782
2990
  box.appendChild(row);
2783
2991
  }
2992
+ if (streamList) {
2993
+ const turnSeq = relayThoughts.get(String(messageId || thinkingMessageId || ''))?.get(key)?.turnSeq;
2994
+ if (turnSeq != null && row.dataset.turnSeq !== String(turnSeq)) {
2995
+ row.dataset.turnSeq = String(turnSeq);
2996
+ placeByTurnSeq(streamList, row);
2997
+ }
2998
+ }
2784
2999
  const summaryEl = row.querySelector('summary');
2785
3000
  const bodyEl = row.querySelector('.thinking-thought-body');
2786
3001
  if (summaryEl) summaryEl.textContent = `💭 ${thoughtSummaryText(value)}`;
@@ -2795,7 +3010,7 @@ export function renderThinkingThoughts() {
2795
3010
  const thoughtMap = thinkingMessageId ? relayThoughts.get(thinkingMessageId) : null;
2796
3011
  if (!thoughtMap || !thoughtMap.size) return;
2797
3012
  for (const entry of thoughtMap.values()) {
2798
- appendThinkingThought(entry.reasoningId, entry.text, entry.done, entry.subagentRunId || null, false);
3013
+ appendThinkingThought(entry.reasoningId, entry.text, entry.done, entry.subagentRunId || null, false, thinkingMessageId);
2799
3014
  }
2800
3015
  }
2801
3016
 
@@ -3735,8 +3950,12 @@ export function appendMessage(msg, scroll = true, msgId = null, force = false, i
3735
3950
  }
3736
3951
  }
3737
3952
  if (isNewNode && insertedNode) {
3738
- // A reply takes over the board its turn posted (relay-board-view).
3739
- if (msg?.role === 'assistant') renderRelayBoards();
3953
+ // A reply takes over the board its turn posted (relay-board-view), and
3954
+ // the 📓 stubs of its asks.
3955
+ if (msg?.role === 'assistant') {
3956
+ renderRelayBoards();
3957
+ renderQuestionStubs();
3958
+ }
3740
3959
  syncSeparatorsNow();
3741
3960
  if (scroll) scrollBottom();
3742
3961
  }
@@ -3770,6 +3989,8 @@ function buildMessageSnapshotKey(messages = [], meta = {}) {
3770
3989
  conversationId,
3771
3990
  hasMoreOlder: !!hasMoreOlder,
3772
3991
  hasMoreNewer: !!hasMoreNewer,
3992
+ // The stream view lays the same payload out differently.
3993
+ streamView: isStreamViewEnabled(),
3773
3994
  messages: (Array.isArray(messages) ? messages : []).map((item) => ({
3774
3995
  id: String(item?.id || '').trim(),
3775
3996
  role: String(item?.role || '').trim(),
@@ -3797,7 +4018,11 @@ function buildMessageSnapshotKey(messages = [], meta = {}) {
3797
4018
  done: !!thought?.done,
3798
4019
  timestamp: String(thought?.timestamp || '').trim(),
3799
4020
  subagentRunId: String(thought?.subagentRunId || '').trim(),
4021
+ turnSeq: normalizeTurnSeq(thought?.turnSeq),
3800
4022
  })),
4023
+ // The stream view orders by these: the count and the numbers.
4024
+ activityOrder: (Array.isArray(item?.activities) ? item.activities : [])
4025
+ .map((activity) => normalizeTurnSeq(activity?.turnSeq) ?? '-').join(','),
3801
4026
  // Cheap signature only — runs are immutable once persisted, so runId +
3802
4027
  // status is enough to catch a payload that differs only in its runs
3803
4028
  // (without one, such a payload would short-circuit and the card would
@@ -3909,9 +4134,41 @@ export function renderNoConversationState() {
3909
4134
  return true;
3910
4135
  }
3911
4136
 
4137
+ // The last payload renderMessages (or a window merge) was given: the
4138
+ // fallback for a change of layout when no window is kept (the shared view).
4139
+ let lastRenderMessagesArgs = null;
4140
+
4141
+ /**
4142
+ * The stream view was switched: the transcript's assistant bubbles and the
4143
+ * live bubble take the other layout now. The messages are the kept window of
4144
+ * the conversation on screen (conversation-payload-cache.mjs), which the live
4145
+ * rows from the socket and every merge keep current; the last rendered
4146
+ * payload only when there is none.
4147
+ */
4148
+ export function refreshStreamViewLayout() {
4149
+ const conversationId = String(currentConvId || '').trim();
4150
+ const kept = conversationId ? conversationPayloadCache.peekWindow(conversationId) : null;
4151
+ // In place: the reader keeps the text they are at.
4152
+ if (kept?.messages?.length) {
4153
+ const flags = { hasMoreOlder: kept.hasMoreOlder, hasMoreNewer: kept.hasMoreNewer };
4154
+ renderMessagesInPlace(kept.messages, { conversationId, ...flags, pageInfo: windowPageInfo(kept.messages, flags) });
4155
+ } else if (lastRenderMessagesArgs) {
4156
+ const { msgs, meta } = lastRenderMessagesArgs;
4157
+ renderMessagesInPlace(msgs, meta);
4158
+ }
4159
+ const messageId = thinkingMessageId;
4160
+ if (messageId && document.getElementById('thinking-indicator')) {
4161
+ takeThinkingBubbleOff();
4162
+ showThinking(messageId, false);
4163
+ renderThinkingActivities();
4164
+ renderRestoredSubagentBubbles(messageId);
4165
+ }
4166
+ }
4167
+
3912
4168
  export function renderMessages(msgs, scroll = true, meta = {}) {
3913
4169
  const el = getMessagesElement();
3914
4170
  if (!el) return false;
4171
+ lastRenderMessagesArgs = { msgs, meta };
3915
4172
  const ordered = sortConversationMessages(msgs || []);
3916
4173
  const snapshotKey = buildMessageSnapshotKey(ordered, meta);
3917
4174
  const statusViewMounted = !!el.querySelector('.status-view');
@@ -4436,6 +4693,7 @@ export function mergeMessagesIntoTranscript(previousMessages, msgs, meta = {}) {
4436
4693
  renderRelayQuestions();
4437
4694
  renderRelayBoards();
4438
4695
  lastRenderedMessageSnapshotKey = snapshotKey;
4696
+ lastRenderMessagesArgs = { msgs, meta };
4439
4697
  return true;
4440
4698
  }
4441
4699
 
@@ -4755,6 +5013,8 @@ export async function sendMessage() {
4755
5013
  const adoptNewConversation = viewingSendConversation();
4756
5014
  if (adoptNewConversation) {
4757
5015
  setCurrentConv(r.conversationId);
5016
+ // The new chat gets its history entry, as one opened from the list does.
5017
+ noteConversationShown(r.conversationId);
4758
5018
  composerConversationId = r.conversationId;
4759
5019
  nameOwnSendConversation(send, r.conversationId);
4760
5020
  }
@@ -2,6 +2,7 @@
2
2
  // Kept DOM-free so they can run under `node --test` without a browser.
3
3
 
4
4
  const FLIP_THRESHOLD_PX = 180;
5
+ const FALLBACK_WIDTH_PX = 260;
5
6
 
6
7
  function clamp(value, min, max) {
7
8
  if (max < min) return min;
@@ -11,17 +12,28 @@ function clamp(value, min, max) {
11
12
  /**
12
13
  * Decide where the fixed-position picker panel should sit relative to its trigger.
13
14
  * Used on desktop only; the mobile layout is a bottom sheet driven purely by CSS.
15
+ *
16
+ * `gap`, `edge`, `minHeight`, `maxHeight` are design sizes at 100 %; `scale`
17
+ * is the UI zoom (font-scaling.js getUiScale) they are multiplied by, so the
18
+ * panel keeps its proportions next to the scaled stylesheet. The trigger rect
19
+ * and the viewport are measured, never scaled.
14
20
  */
15
21
  export function resolveCwdMenuPlacement({
16
22
  triggerRect,
17
23
  viewportWidth = 0,
18
24
  viewportHeight = 0,
19
25
  panelHeight = 0,
20
- gap = 6,
21
- edge = 8,
22
- minHeight = 140,
23
- maxHeight = 300,
26
+ gap: gapBase = 6,
27
+ edge: edgeBase = 8,
28
+ minHeight: minHeightBase = 140,
29
+ maxHeight: maxHeightBase = 300,
30
+ scale = 1,
24
31
  } = {}) {
32
+ const factor = Number.isFinite(Number(scale)) && Number(scale) > 0 ? Number(scale) : 1;
33
+ const gap = gapBase * factor;
34
+ const edge = edgeBase * factor;
35
+ const minHeight = minHeightBase * factor;
36
+ const maxHeight = maxHeightBase * factor;
25
37
  const top = Number(triggerRect?.top) || 0;
26
38
  const left = Number(triggerRect?.left) || 0;
27
39
  const width = Math.max(0, Number(triggerRect?.width) || 0);
@@ -30,13 +42,13 @@ export function resolveCwdMenuPlacement({
30
42
 
31
43
  const spaceBelow = Math.max(0, viewportHeight - bottom - gap - edge);
32
44
  const spaceAbove = Math.max(0, top - gap - edge);
33
- const flip = spaceBelow < FLIP_THRESHOLD_PX && spaceAbove > spaceBelow;
45
+ const flip = spaceBelow < FLIP_THRESHOLD_PX * factor && spaceAbove > spaceBelow;
34
46
  const available = flip ? spaceAbove : spaceBelow;
35
47
 
36
48
  const desiredHeight = panelHeight > 0 ? Math.min(panelHeight, maxHeight) : maxHeight;
37
49
  const resolvedMaxHeight = clamp(Math.min(desiredHeight, available), Math.min(minHeight, available), maxHeight);
38
50
 
39
- const resolvedWidth = width > 0 ? width : 260;
51
+ const resolvedWidth = width > 0 ? width : FALLBACK_WIDTH_PX * factor;
40
52
  const maxLeft = Math.max(edge, viewportWidth - resolvedWidth - edge);
41
53
  const resolvedLeft = clamp(left, edge, maxLeft);
42
54
  const resolvedTop = flip