@oar-sh/oar 0.9.21 → 0.9.23

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 (82) hide show
  1. package/CHANGELOG.md +206 -0
  2. package/README.md +9 -9
  3. package/bin/oar.js +28 -0
  4. package/package.json +13 -14
  5. package/server/claude-cloud-worker/claude-cloud-session-worker.mjs +9 -1
  6. package/server/claude-worker/claude-cli-pinned-env.mjs +4 -1
  7. package/server/claude-worker/claude-sdk-adapter.mjs +149 -4
  8. package/server/claude-worker/claude-session-process.mjs +9 -4
  9. package/server/claude-worker/claude-session-worker-main.mjs +10 -1
  10. package/server/copilot-worker/copilot-sdk-session-process.mjs +1 -0
  11. package/server/copilot-worker/copilot-sdk-session-worker.mjs +10 -1
  12. package/server/cursor-worker/cursor-session-worker.mjs +9 -1
  13. package/server/db-schema.mjs +19 -0
  14. package/server/grok-worker/grok-background-tasks.mjs +1 -0
  15. package/server/grok-worker/grok-session-worker.mjs +10 -1
  16. package/server/grok-worker/grok-turn-runner.mjs +1 -0
  17. package/server/migrations/0037-relay-asks-queue-id.mjs +66 -0
  18. package/server/migrations/0038-turn-seq.mjs +92 -0
  19. package/server/public/app/activity-replay-state.mjs +160 -42
  20. package/server/public/app/api-client.js +7 -0
  21. package/server/public/app/ask-user-view.js +4 -3
  22. package/server/public/app/bootstrap.js +62 -4
  23. package/server/public/app/chat-actions-menu-state.mjs +2 -0
  24. package/server/public/app/context-usage-view.mjs +68 -0
  25. package/server/public/app/conversation-view.js +306 -49
  26. package/server/public/app/header-priority-actions.mjs +5 -1
  27. package/server/public/app/held-details.mjs +202 -0
  28. package/server/public/app/inbox-ui.js +70 -79
  29. package/server/public/app/inbox-view.mjs +35 -19
  30. package/server/public/app/journal-view.js +17 -0
  31. package/server/public/app/live-activity-cap.mjs +103 -0
  32. package/server/public/app/questions-panel-ui.js +634 -0
  33. package/server/public/app/questions-stub-view.mjs +40 -0
  34. package/server/public/app/questions-view.mjs +89 -0
  35. package/server/public/app/relay-board-view.js +12 -2
  36. package/server/public/app/relay-thoughts.mjs +15 -1
  37. package/server/public/app/session-history-ui.js +1 -0
  38. package/server/public/app/settings-modal.js +6 -0
  39. package/server/public/app/socket-handlers.js +42 -11
  40. package/server/public/app/stream-view-preference.mjs +60 -0
  41. package/server/public/app/subagent-fold.mjs +6 -3
  42. package/server/public/app/subagent-timeline-view.mjs +12 -5
  43. package/server/public/app/subagent-viewer.mjs +8 -6
  44. package/server/public/app/thoughts-preference.mjs +79 -0
  45. package/server/public/app/turn-stream-view.mjs +124 -0
  46. package/server/public/index.html +391 -214
  47. package/server/repositories/message-repository.mjs +10 -0
  48. package/server/repositories/question-repository.mjs +51 -7
  49. package/server/repositories/relay-turn-rows.mjs +56 -0
  50. package/server/routes/ask-user-routes.mjs +34 -5
  51. package/server/routes/messages-routes.mjs +46 -34
  52. package/server/routes/relay-ask-routes.mjs +15 -5
  53. package/server/routes/sessions-routes.mjs +77 -6
  54. package/server/server-runtime.mjs +199 -171
  55. package/server/services/cloudflared-tunnel-service.mjs +7 -0
  56. package/server/services/earlier-database-service.mjs +1 -1
  57. package/server/services/file-serving.mjs +13 -0
  58. package/server/services/idle-timers.mjs +184 -0
  59. package/server/services/native-addon-build-service.mjs +224 -0
  60. package/server/services/oar-cli-helpers.mjs +7 -4
  61. package/server/services/queue-wake-hook.mjs +145 -0
  62. package/server/services/queue-waker.mjs +127 -0
  63. package/server/services/relay-ask-service.mjs +64 -5
  64. package/server/services/relay-console-log.mjs +2 -3
  65. package/server/services/relay-event-service.mjs +4 -1
  66. package/server/services/remote-relay-dispatcher.mjs +23 -4
  67. package/server/services/remote-relay-loopback.mjs +3 -3
  68. package/server/services/sdk-session-sync-service.mjs +1 -1
  69. package/server/services/session-history-refresh-service.mjs +4 -0
  70. package/server/services/session-worker-supervisor-service.mjs +36 -0
  71. package/server/services/session-worker-websocket-service.mjs +143 -15
  72. package/server/services/ssh-tunnel-manager-service.mjs +7 -0
  73. package/server/services/update-install-service.mjs +32 -1
  74. package/shared/ask-user-bridge.mjs +9 -6
  75. package/shared/relay-asks.mjs +10 -0
  76. package/shared/relay-events.mjs +1 -0
  77. package/shared/worker-runtime/heartbeat.mjs +26 -0
  78. package/shared/worker-runtime/worker-keepalive.mjs +108 -0
  79. package/shared/worker-runtime/worker-websocket-link.mjs +21 -10
  80. package/server/services/package-data-rescue-preinstall.mjs +0 -22
  81. package/server/services/package-data-rescue.mjs +0 -139
  82. package/server/tty-console-bootstrap.mjs +0 -75
@@ -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,7 @@ 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';
158
165
  import { noteConversationShown } from './session-history-ui.js';
159
166
 
160
167
  const CONVERSATION_HISTORY_PAGE_SIZE = 20;
@@ -1613,9 +1620,10 @@ function createMessageNode(msg, msgId = null, force = false) {
1613
1620
  const content = renderMarkdownPreview(msg.text || '', false);
1614
1621
  const attachments = Array.isArray(msg.attachments) ? msg.attachments : [];
1615
1622
  // capRelayActivityEntries, not slice: a boundary past the cap would take
1616
- // 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.
1617
1625
  const activities = Array.isArray(msg.activities)
1618
- ? capRelayActivityEntries(msg.activities.filter(Boolean), 48)
1626
+ ? capFinishedActivityEntries(msg.activities.filter(Boolean))
1619
1627
  : [];
1620
1628
  // A compaction that happened during this turn is rendered as a full-width
1621
1629
  // break row immediately above this message (syncTranscriptSeparators reads
@@ -1634,8 +1642,27 @@ function createMessageNode(msg, msgId = null, force = false) {
1634
1642
  const subagentRuns = Array.isArray(msg.subagentRuns) ? msg.subagentRuns.filter(Boolean) : [];
1635
1643
  const mainActivities = activities.filter((item) => !(normalizeRelayActivityEntry(item)?.subagentRunId));
1636
1644
  const mainThoughts = thoughts.filter((t) => !t?.subagentRunId);
1637
- const activityHtml = mainActivities.length ? renderActivityMarkup(mainActivities, promotedBoundaryEntry) : '';
1638
- 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;
1639
1666
  // One row per sub-agent the turn spawned, as in the composer's task panel;
1640
1667
  // its title opens the agent's whole record. The store's copy wins: status
1641
1668
  // events keep it current after the bubble was built.
@@ -1720,7 +1747,7 @@ function createMessageNode(msg, msgId = null, force = false) {
1720
1747
  : '';
1721
1748
 
1722
1749
  div.innerHTML = `
1723
- <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>
1724
1751
  <div class="msg-label">${label}${modelTag}${reasoningTag}${modeTag}${autoTag}${continuationTag}${crossProviderTag}${usageTurnTag}${usageRemainingTag}${usageStaleTag} · ${fmtDate(msg.timestamp)}</div>`;
1725
1752
 
1726
1753
  const bubble = div.querySelector('.msg-bubble');
@@ -1915,10 +1942,12 @@ export function renderActivityMarkup(activities, promotedBoundaryEntry = undefin
1915
1942
  const progressHtml = progress.length
1916
1943
  ? `<div class="msg-activity-list">${progress.map((item) => `<div class="msg-activity-item">${escHtml(decorateActivityText(relayActivityEntryText(item)))}</div>`).join('')}</div>`
1917
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;
1918
1947
  const toolsHtml = tools.length
1919
1948
  ? `
1920
1949
  <details class="msg-activity">
1921
- <summary>🔧 Tool activity (${tools.length})</summary>
1950
+ <summary>🔧 Tool activity (${toolCount})</summary>
1922
1951
  <div class="msg-activity-list">${tools.map((item) => `<div class="msg-activity-item">${escHtml(decorateActivityText(relayActivityEntryText(item)))}</div>`).join('')}</div>
1923
1952
  </details>`
1924
1953
  : '';
@@ -2012,6 +2041,7 @@ export function showThinking(messageId = null, autoScroll = true) {
2012
2041
  <summary>💭 Thoughts</summary>
2013
2042
  <div class="thinking-thoughts-list"></div>
2014
2043
  </details>
2044
+ ${isStreamViewEnabled() ? '<div id="thinking-stream-list" class="thinking-stream-list"></div>' : ''}
2015
2045
  <div id="thinking-stream" class="thinking-stream" hidden></div>
2016
2046
  <div class="thinking-compacting-note" hidden>Compacting the conversation…</div>
2017
2047
  <div class="dots"><span></span><span></span><span></span></div>
@@ -2022,8 +2052,10 @@ export function showThinking(messageId = null, autoScroll = true) {
2022
2052
  syncThinkingCompaction(div);
2023
2053
  renderThinkingThoughts();
2024
2054
  renderThinkingStream();
2025
- // 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.
2026
2057
  renderRelayBoards();
2058
+ renderQuestionStubs();
2027
2059
  observeThinkingBubble(div);
2028
2060
  if (getThinkingFollowMode()) applyThinkingFollow();
2029
2061
  else if (autoScroll) scrollBottom();
@@ -2158,7 +2190,7 @@ export function collapseThinkingThoughts() {
2158
2190
  if (!(panel instanceof HTMLDetailsElement)) return;
2159
2191
  panel.open = false;
2160
2192
  panel.querySelectorAll('.thinking-thought').forEach((row) => {
2161
- if (row instanceof HTMLDetailsElement) row.open = false;
2193
+ if (row instanceof HTMLDetailsElement) showLiveThoughtRow(row, false);
2162
2194
  });
2163
2195
  }
2164
2196
 
@@ -2252,6 +2284,7 @@ export function renderThinkingActivities() {
2252
2284
  // clear-and-replay (or the append path's last-row-only dedupe) would either
2253
2285
  // wipe selections or duplicate rows on every changed payload.
2254
2286
  const mainExpected = [];
2287
+ const mainEntries = [];
2255
2288
  const bySubagentRun = new Map();
2256
2289
  for (const item of items) {
2257
2290
  const entry = normalizeRelayActivityEntry(item);
@@ -2261,15 +2294,22 @@ export function renderThinkingActivities() {
2261
2294
  // would otherwise flip presentation the moment the turn lands.
2262
2295
  if (isCompactBoundaryActivityEntry(entry)) continue;
2263
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;
2264
2300
  if (entry.subagentRunId) {
2265
2301
  const list = bySubagentRun.get(entry.subagentRunId) || [];
2266
- if (list[list.length - 1] !== decorated) list.push(decorated);
2302
+ if (numbered || list[list.length - 1] !== decorated) list.push(decorated);
2267
2303
  bySubagentRun.set(entry.subagentRunId, list);
2268
- } else if (mainExpected[mainExpected.length - 1] !== decorated) {
2304
+ } else if (numbered || mainExpected[mainExpected.length - 1] !== decorated) {
2269
2305
  mainExpected.push(decorated);
2306
+ mainEntries.push({ text: decorated, turnSeq: entry.turnSeq ?? null });
2270
2307
  }
2271
2308
  }
2272
- if (!selectionIntersectsNode(box)) {
2309
+ const streamList = liveStreamList();
2310
+ if (streamList) {
2311
+ if (!selectionIntersectsNode(streamList)) patchStreamListActivities(streamList, mainEntries);
2312
+ } else if (!selectionIntersectsNode(box)) {
2273
2313
  patchActivityList(box, mainExpected, 'thinking-activity-item');
2274
2314
  }
2275
2315
  syncThinkingCompaction();
@@ -2297,7 +2337,9 @@ export function restoreInFlightThinking(inFlight, autoScroll = true) {
2297
2337
  // live poll would otherwise churn the DOM (and the user's selection) with
2298
2338
  // identical content. The indicator must belong to this turn's message —
2299
2339
  // skipping on a foreign bubble would let a misplaced one stick around.
2300
- 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)}`;
2301
2343
  const liveIndicator = document.getElementById('thinking-indicator');
2302
2344
  if (snapshotKey === lastInFlightSnapshotKey
2303
2345
  && liveIndicator
@@ -2313,6 +2355,7 @@ export function restoreInFlightThinking(inFlight, autoScroll = true) {
2313
2355
  const activities = mergeRelayActivityTexts(
2314
2356
  relayActivities.get(messageId) || [],
2315
2357
  Array.isArray(inFlight.activities) ? inFlight.activities : [],
2358
+ liveActivityCap(),
2316
2359
  );
2317
2360
  relayActivities.set(messageId, activities);
2318
2361
  const inFlightThoughts = Array.isArray(inFlight.thoughts) ? inFlight.thoughts : [];
@@ -2320,7 +2363,15 @@ export function restoreInFlightThinking(inFlight, autoScroll = true) {
2320
2363
  const thoughtMap = relayThoughts.get(messageId) || new Map();
2321
2364
  for (const entry of inFlightThoughts) {
2322
2365
  const key = String(entry?.reasoningId || `seq-${entry?.seq || thoughtMap.size}`);
2323
- 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
+ });
2324
2375
  }
2325
2376
  relayThoughts.set(messageId, thoughtMap);
2326
2377
  }
@@ -2405,6 +2456,7 @@ export function appendThinkingActivity(item, subagentRunId = null, autoScroll =
2405
2456
  row.className = 'subagent-activity-item';
2406
2457
  row.textContent = decorated;
2407
2458
  activityBox.appendChild(row);
2459
+ trimLiveActivityRows(activityBox, 'subagent-activity-item');
2408
2460
  refreshSubagentSummary(subagentBubble);
2409
2461
  }
2410
2462
  }
@@ -2415,18 +2467,105 @@ export function appendThinkingActivity(item, subagentRunId = null, autoScroll =
2415
2467
  return;
2416
2468
  }
2417
2469
 
2418
- 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');
2419
2474
  if (!box) return;
2420
- const last = box.lastElementChild?.textContent || '';
2421
- 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
+ }
2422
2485
  const row = document.createElement('div');
2423
2486
  row.className = 'thinking-activity-item';
2424
- if (subagentRunId) row.dataset.subagentRunId = subagentRunId;
2487
+ if (numbered) row.dataset.turnSeq = String(entry.turnSeq);
2425
2488
  row.textContent = decorated;
2426
- box.appendChild(row);
2489
+ if (streamList) placeByTurnSeq(streamList, row);
2490
+ else box.appendChild(row);
2491
+ trimLiveActivityRows(box, 'thinking-activity-item');
2427
2492
  if (autoScroll) scrollBottom();
2428
2493
  }
2429
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
+
2430
2569
  function getSubagentDisplayName(subagentRunId) {
2431
2570
  const entry = typeof getSubagentRun === 'function' ? getSubagentRun(subagentRunId) : null;
2432
2571
  if (entry?.displayName) return entry.displayName;
@@ -2568,7 +2707,11 @@ function ensureSubagentBubble(subagentRunId, depth = 0) {
2568
2707
  }
2569
2708
  }
2570
2709
  if (entry?.activities?.length) {
2571
- 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) {
2572
2715
  const text = typeof item === 'string' ? item : String(item?.text || '').trim();
2573
2716
  if (!text) continue;
2574
2717
  const decorated = decorateActivityText(text);
@@ -2595,6 +2738,7 @@ function refreshSubagentSummary(bubble) {
2595
2738
  const text = subagentFoldSummary({
2596
2739
  activities: Array.from(bubble.querySelectorAll(':scope > .subagent-activity > .subagent-activity-item'), (row) => row.textContent || ''),
2597
2740
  streamText: bubble.querySelector(':scope > .subagent-stream')?.textContent || '',
2741
+ cap: liveActivityCap(),
2598
2742
  });
2599
2743
  if (summary.textContent !== text) summary.textContent = text;
2600
2744
  }
@@ -2717,17 +2861,95 @@ function thoughtSummaryText(text) {
2717
2861
  return value.length > 80 ? `${value.slice(0, 80)}…` : value;
2718
2862
  }
2719
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
+ */
2720
2869
  export function setLiveThinkingThoughtState(row, done = false) {
2721
2870
  if (!row) return;
2722
2871
  row.dataset.done = done ? '1' : '0';
2723
- // The temporary panel represents an active turn, so every thought remains visible
2724
- // until the completed assistant message replaces it with collapsed history.
2725
- row.open = true;
2726
2872
  }
2727
2873
 
2728
- 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) {
2729
2950
  const key = String(reasoningId || 'reasoning');
2730
2951
  const value = String(text || '');
2952
+ const turnId = liveThoughtTurnId(messageId, subagentRunId);
2731
2953
 
2732
2954
  if (subagentRunId) {
2733
2955
  const subagentBubble = ensureSubagentBubble(subagentRunId);
@@ -2742,16 +2964,7 @@ export function appendThinkingThought(reasoningId, text, done = false, subagentR
2742
2964
  }
2743
2965
  let row = thoughtsBox.querySelector(`.thinking-thought[data-reasoning-id="${CSS.escape(key)}"]`);
2744
2966
  if (!row) {
2745
- row = document.createElement('details');
2746
- row.className = 'thinking-thought';
2747
- row.open = !done;
2748
- row.dataset.reasoningId = key;
2749
- row.dataset.subagentRunId = subagentRunId;
2750
- const summary = document.createElement('summary');
2751
- const body = document.createElement('div');
2752
- body.className = 'thinking-thought-body';
2753
- row.appendChild(summary);
2754
- row.appendChild(body);
2967
+ row = createLiveThoughtRow(key, subagentRunId, turnId);
2755
2968
  thoughtsBox.appendChild(row);
2756
2969
  }
2757
2970
  const summaryEl = row.querySelector('summary');
@@ -2766,22 +2979,23 @@ export function appendThinkingThought(reasoningId, text, done = false, subagentR
2766
2979
  return;
2767
2980
  }
2768
2981
 
2769
- 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');
2770
2986
  if (!box) return;
2771
2987
  let row = box.querySelector(`.thinking-thought[data-reasoning-id="${CSS.escape(key)}"]`);
2772
2988
  if (!row) {
2773
- row = document.createElement('details');
2774
- row.className = 'thinking-thought';
2775
- row.open = !done;
2776
- row.dataset.reasoningId = key;
2777
- if (subagentRunId) row.dataset.subagentRunId = subagentRunId;
2778
- const summary = document.createElement('summary');
2779
- const body = document.createElement('div');
2780
- body.className = 'thinking-thought-body';
2781
- row.appendChild(summary);
2782
- row.appendChild(body);
2989
+ row = createLiveThoughtRow(key, subagentRunId, turnId);
2783
2990
  box.appendChild(row);
2784
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
+ }
2785
2999
  const summaryEl = row.querySelector('summary');
2786
3000
  const bodyEl = row.querySelector('.thinking-thought-body');
2787
3001
  if (summaryEl) summaryEl.textContent = `💭 ${thoughtSummaryText(value)}`;
@@ -2796,7 +3010,7 @@ export function renderThinkingThoughts() {
2796
3010
  const thoughtMap = thinkingMessageId ? relayThoughts.get(thinkingMessageId) : null;
2797
3011
  if (!thoughtMap || !thoughtMap.size) return;
2798
3012
  for (const entry of thoughtMap.values()) {
2799
- appendThinkingThought(entry.reasoningId, entry.text, entry.done, entry.subagentRunId || null, false);
3013
+ appendThinkingThought(entry.reasoningId, entry.text, entry.done, entry.subagentRunId || null, false, thinkingMessageId);
2800
3014
  }
2801
3015
  }
2802
3016
 
@@ -3736,8 +3950,12 @@ export function appendMessage(msg, scroll = true, msgId = null, force = false, i
3736
3950
  }
3737
3951
  }
3738
3952
  if (isNewNode && insertedNode) {
3739
- // A reply takes over the board its turn posted (relay-board-view).
3740
- 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
+ }
3741
3959
  syncSeparatorsNow();
3742
3960
  if (scroll) scrollBottom();
3743
3961
  }
@@ -3771,6 +3989,8 @@ function buildMessageSnapshotKey(messages = [], meta = {}) {
3771
3989
  conversationId,
3772
3990
  hasMoreOlder: !!hasMoreOlder,
3773
3991
  hasMoreNewer: !!hasMoreNewer,
3992
+ // The stream view lays the same payload out differently.
3993
+ streamView: isStreamViewEnabled(),
3774
3994
  messages: (Array.isArray(messages) ? messages : []).map((item) => ({
3775
3995
  id: String(item?.id || '').trim(),
3776
3996
  role: String(item?.role || '').trim(),
@@ -3798,7 +4018,11 @@ function buildMessageSnapshotKey(messages = [], meta = {}) {
3798
4018
  done: !!thought?.done,
3799
4019
  timestamp: String(thought?.timestamp || '').trim(),
3800
4020
  subagentRunId: String(thought?.subagentRunId || '').trim(),
4021
+ turnSeq: normalizeTurnSeq(thought?.turnSeq),
3801
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(','),
3802
4026
  // Cheap signature only — runs are immutable once persisted, so runId +
3803
4027
  // status is enough to catch a payload that differs only in its runs
3804
4028
  // (without one, such a payload would short-circuit and the card would
@@ -3910,9 +4134,41 @@ export function renderNoConversationState() {
3910
4134
  return true;
3911
4135
  }
3912
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
+
3913
4168
  export function renderMessages(msgs, scroll = true, meta = {}) {
3914
4169
  const el = getMessagesElement();
3915
4170
  if (!el) return false;
4171
+ lastRenderMessagesArgs = { msgs, meta };
3916
4172
  const ordered = sortConversationMessages(msgs || []);
3917
4173
  const snapshotKey = buildMessageSnapshotKey(ordered, meta);
3918
4174
  const statusViewMounted = !!el.querySelector('.status-view');
@@ -4437,6 +4693,7 @@ export function mergeMessagesIntoTranscript(previousMessages, msgs, meta = {}) {
4437
4693
  renderRelayQuestions();
4438
4694
  renderRelayBoards();
4439
4695
  lastRenderedMessageSnapshotKey = snapshotKey;
4696
+ lastRenderMessagesArgs = { msgs, meta };
4440
4697
  return true;
4441
4698
  }
4442
4699
 
@@ -12,6 +12,7 @@
12
12
  // action never "jumps" a higher one back into the header.
13
13
 
14
14
  export const HEADER_PRIORITY_ACTIONS = Object.freeze([
15
+ Object.freeze({ key: 'questions', inlineId: 'questions-btn', menuId: 'chat-menu-questions' }),
15
16
  Object.freeze({ key: 'pins', inlineId: 'pinned-messages-btn', menuId: 'chat-menu-pins' }),
16
17
  Object.freeze({ key: 'hidden', inlineId: 'hidden-messages-btn', menuId: 'chat-menu-hidden' }),
17
18
  Object.freeze({ key: 'search', inlineId: 'message-search-btn', menuId: 'chat-menu-search' }),
@@ -140,6 +141,7 @@ export function resolveQuestionBannerMode({
140
141
  *
141
142
  * @param {object} input
142
143
  * @param {boolean} [input.sharedMode] the shared read-only view: Day/Night only
144
+ * @param {number} [input.questionCount] pending questions (📓) of the open conversation
143
145
  * @param {number} [input.pinCount] pins of the open conversation
144
146
  * @param {number} [input.hiddenCount] messages hidden from shared viewers
145
147
  * @param {boolean} [input.fullscreenAvailable] pwa-install.js's verdict (a phone, not installed)
@@ -147,14 +149,16 @@ export function resolveQuestionBannerMode({
147
149
  */
148
150
  export function resolveHeaderActionAvailability({
149
151
  sharedMode = false,
152
+ questionCount = 0,
150
153
  pinCount = 0,
151
154
  hiddenCount = 0,
152
155
  fullscreenAvailable = false,
153
156
  } = {}) {
154
157
  if (sharedMode) {
155
- return { pins: false, hidden: false, search: false, context: false, usage: false, theme: true, fullscreen: false };
158
+ return { questions: false, pins: false, hidden: false, search: false, context: false, usage: false, theme: true, fullscreen: false };
156
159
  }
157
160
  return {
161
+ questions: Number(questionCount) > 0,
158
162
  pins: Number(pinCount) > 0,
159
163
  hidden: Number(hiddenCount) > 0,
160
164
  search: true,