@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
@@ -1,4 +1,10 @@
1
- const DEFAULT_ACTIVITY_LIMIT = 24;
1
+ // A row's place in its turn's order (migration 0038), or null: rows from
2
+ // before it, or from an older relay, have none.
3
+ export function normalizeTurnSeq(value) {
4
+ if (value === null || value === undefined || value === '') return null;
5
+ const num = Number(value);
6
+ return Number.isInteger(num) && num > 0 ? num : null;
7
+ }
2
8
 
3
9
  export function normalizeRelayActivityEntry(item) {
4
10
  if (item && typeof item === 'object' && !Array.isArray(item)) {
@@ -8,7 +14,8 @@ export function normalizeRelayActivityEntry(item) {
8
14
  const metadata = (item.metadata && typeof item.metadata === 'object' && !Array.isArray(item.metadata))
9
15
  ? item.metadata
10
16
  : null;
11
- return { text, subagentRunId, metadata };
17
+ const turnSeq = normalizeTurnSeq(item.turnSeq);
18
+ return { text, subagentRunId, metadata, ...(turnSeq !== null ? { turnSeq } : {}) };
12
19
  }
13
20
  const text = String(item || '').trim();
14
21
  if (!text) return null;
@@ -24,25 +31,87 @@ export function isCompactBoundaryActivityEntry(item) {
24
31
  return normalizeRelayActivityEntry(item)?.metadata?.kind === 'compact_boundary';
25
32
  }
26
33
 
27
- // The live list of one turn keeps its newest ordinary lines only, but every
28
- // structured row a line of the transcript is built from: the turn's
29
- // compaction entries and the session-restart notes (a compaction window, a
30
- // billing switch) would otherwise scroll out of a busy turn and take their
31
- // line with them until the next reload.
32
- export const LIVE_ACTIVITY_CAP = 24;
34
+ // The thread an activity row belongs to: a sub-agent run, or '' for the main
35
+ // agent. Every cap below counts each thread apart, so a busy sub-agent never
36
+ // pushes the main agent's lines out (or another sub-agent's).
37
+ function activityThreadKey(item) {
38
+ return normalizeRelayActivityEntry(item)?.subagentRunId || '';
39
+ }
40
+
41
+ // No limit: a cap of 0 (the setting's far end), or anything not a positive
42
+ // number.
43
+ function isUnlimitedCap(cap) {
44
+ const value = Number(cap);
45
+ return !Number.isFinite(value) || value <= 0;
46
+ }
47
+
48
+ /**
49
+ * Keep the newest `cap` rows of each thread among those `isKept` does not
50
+ * protect, and every protected row; the order stays. A sub-agent's thread
51
+ * keeps `subagentCap` (the same unless given). A list already within the cap
52
+ * is returned as it is.
53
+ */
54
+ function capNewestPerThread(list, cap, isKept, subagentCap = cap) {
55
+ if (isUnlimitedCap(cap) && isUnlimitedCap(subagentCap)) return list;
56
+ const maxFor = (key) => {
57
+ const limit = key ? subagentCap : cap;
58
+ return isUnlimitedCap(limit) ? Number.POSITIVE_INFINITY : Math.trunc(Number(limit));
59
+ };
60
+ const remaining = new Map();
61
+ for (const item of list) {
62
+ if (isKept(item)) continue;
63
+ const key = activityThreadKey(item);
64
+ remaining.set(key, (remaining.get(key) || 0) + 1);
65
+ }
66
+ if ([...remaining.entries()].every(([key, count]) => count <= maxFor(key))) return list;
67
+ return list.filter((item) => {
68
+ if (isKept(item)) return true;
69
+ const key = activityThreadKey(item);
70
+ const left = remaining.get(key) - 1;
71
+ remaining.set(key, left);
72
+ return left < maxFor(key);
73
+ });
74
+ }
75
+
76
+ // The live list of one turn keeps the newest ordinary lines of each thread
77
+ // (the main agent, each sub-agent) only, as many as the "Tool lines shown
78
+ // while an agent works" setting says (live-activity-cap.mjs; 0 keeps all),
79
+ // but every structured row a line of the transcript is built from: the
80
+ // turn's compaction entries and the session-restart notes (a compaction
81
+ // window, a billing switch) would otherwise scroll out of a busy turn and
82
+ // take their line with them until the next reload. The call sites pass the
83
+ // setting; LIVE_ACTIVITY_CAP is its default.
84
+ export const LIVE_ACTIVITY_CAP = 50;
33
85
  function isKeptLiveActivityEntry(item) {
34
86
  const kind = normalizeRelayActivityEntry(item)?.metadata?.kind;
35
87
  return kind === 'compact_boundary' || kind === 'compact_window_respawn' || kind === 'billing_switch_respawn';
36
88
  }
37
89
  export function capLiveActivityEntries(items, cap = LIVE_ACTIVITY_CAP) {
38
90
  const list = Array.isArray(items) ? items : [];
39
- let ordinary = list.reduce((count, item) => count + (isKeptLiveActivityEntry(item) ? 0 : 1), 0);
40
- if (ordinary <= cap) return list;
41
- return list.filter((item) => {
42
- if (isKeptLiveActivityEntry(item)) return true;
43
- ordinary -= 1;
44
- return ordinary < cap;
45
- });
91
+ return capNewestPerThread(list, cap, isKeptLiveActivityEntry);
92
+ }
93
+
94
+ // What the relay sends of a turn's activity rows, per thread.
95
+ //
96
+ // A running turn (the in-flight snapshot the live bubble polls every 900 ms):
97
+ // as many as the browser shows, which it says with `activityCap` (its "Tool
98
+ // lines shown" setting); a request without it (an older page, a paired
99
+ // relay's read) gets the default 50; the setting's "no limit" asks for the
100
+ // most the relay sends, 500.
101
+ export const IN_FLIGHT_ACTIVITY_LIMIT_PER_THREAD = 500;
102
+ // A finished one (the message payload): the main agent's newest 200 (the
103
+ // stream view lists them), each sub-agent's newest 50 (its own record is in
104
+ // the sub-agent viewer).
105
+ export const FINISHED_ACTIVITY_LIMIT_PER_THREAD = 200;
106
+ export const FINISHED_SUBAGENT_ACTIVITY_LIMIT_PER_THREAD = 50;
107
+
108
+ /** The in-flight cap a request asks for: `activityCap` as 1โ€ฆ500, 0 โ†’ 500, else 50. */
109
+ export function inFlightActivityCap(value) {
110
+ const text = String(value ?? '').trim();
111
+ if (!/^\d+$/.test(text)) return LIVE_ACTIVITY_CAP;
112
+ const cap = Number(text);
113
+ if (cap === 0) return IN_FLIGHT_ACTIVITY_LIMIT_PER_THREAD;
114
+ return Math.min(IN_FLIGHT_ACTIVITY_LIMIT_PER_THREAD, cap);
46
115
  }
47
116
 
48
117
  // A compaction is published in up to two steps on the same message: a
@@ -118,56 +187,99 @@ export function visibleActivityEntries(items, promoted) {
118
187
  });
119
188
  }
120
189
 
121
- // Head-cap that never drops structured rows. Activity lists are capped from
122
- // the front (the start of a turn is the interesting part), but a
190
+ // The relay's cap of a turn's activity rows, which never drops structured
191
+ // rows. Each thread (the main agent, each sub-agent) keeps its newest `limit`
192
+ // prose rows: a busy sub-agent no longer pushes the main agent's lines out,
193
+ // and the latest lines are the ones a reader of a long turn is after. A
123
194
  // metadata-bearing row carries transcript structure rather than prose, and a
124
195
  // long agentic turn can push its compaction boundary past the cap โ€” losing
125
196
  // the break row entirely, with no prose fallback since the bubble filters
126
- // compact entries out. Keep every structured row, spend the rest of the
127
- // budget on the leading prose rows, preserve the original order.
128
- export function capRelayActivityEntries(items, limit = DEFAULT_ACTIVITY_LIMIT) {
197
+ // compact entries out. Keep every structured row, preserve the original
198
+ // order. Returns a new array. `subagentLimit` is a sub-agent thread's own
199
+ // limit (the finished payload keeps fewer of those).
200
+ //
201
+ // `omittedMarker`: when the main agent's rows were cut, a structured row
202
+ // saying how many (kind ACTIVITY_OMITTED_KIND) goes right before its first
203
+ // kept row, so a reader of the finished turn sees that its list starts late.
204
+ export function capRelayActivityEntries(items, limit, { subagentLimit = limit, omittedMarker = false } = {}) {
129
205
  const list = Array.isArray(items) ? items : [];
130
- const max = Math.max(1, Math.trunc(Number(limit)) || DEFAULT_ACTIVITY_LIMIT);
131
- if (list.length <= max) return list.slice();
132
- const structuredIndexes = [];
133
- for (let index = 0; index < list.length; index += 1) {
134
- if (normalizeRelayActivityEntry(list[index])?.metadata) structuredIndexes.push(index);
135
- }
136
- if (!structuredIndexes.length) return list.slice(0, max);
206
+ const max = Math.max(1, Math.trunc(Number(limit)) || LIVE_ACTIVITY_CAP);
207
+ const subagentMax = Math.max(1, Math.trunc(Number(subagentLimit)) || max);
208
+ const isStructured = (item) => !!normalizeRelayActivityEntry(item)?.metadata;
209
+ const structured = list.filter(isStructured);
137
210
  // Safety valve for a pathological turn with more structured rows than the
138
211
  // cap allows: the most recent ones win.
139
- const keep = new Set(structuredIndexes.slice(-max));
140
- let budget = max - keep.size;
141
- const kept = [];
142
- for (let index = 0; index < list.length; index += 1) {
143
- if (keep.has(index)) {
144
- kept.push(list[index]);
145
- continue;
146
- }
147
- if (budget <= 0) continue;
148
- budget -= 1;
149
- kept.push(list[index]);
212
+ let source = list;
213
+ if (structured.length > max) {
214
+ const keep = new Set(structured.slice(-max));
215
+ source = list.filter((item) => !isStructured(item) || keep.has(item));
150
216
  }
151
- return kept;
217
+ const capped = capNewestPerThread(source, max, isStructured, subagentMax);
218
+ if (capped === list) return list.slice();
219
+ if (!omittedMarker) return capped;
220
+ const isMainProse = (item) => !isStructured(item) && !activityThreadKey(item);
221
+ const kept = new Set(capped);
222
+ const dropped = source.filter((item) => isMainProse(item) && !kept.has(item));
223
+ if (!dropped.length) return capped;
224
+ const at = capped.findIndex(isMainProse);
225
+ // Where the first line left out was in the turn's order, when it had one.
226
+ const fromTurnSeq = normalizeRelayActivityEntry(dropped[0])?.turnSeq ?? null;
227
+ const marker = omittedActivityMarker(dropped.length, fromTurnSeq);
228
+ return at === -1 ? [...capped, marker] : [...capped.slice(0, at), marker, ...capped.slice(at)];
229
+ }
230
+
231
+ // The row that stands for the main agent's tool lines a finished turn's cap
232
+ // left out. It has no turn order of its own; `fromTurnSeq` is the place of
233
+ // the first line it stands for, where the stream view puts it.
234
+ export const ACTIVITY_OMITTED_KIND = 'activity_omitted';
235
+
236
+ export function omittedActivityMarker(count, fromTurnSeq = null) {
237
+ const n = Math.max(1, Math.trunc(Number(count)) || 1);
238
+ const from = normalizeTurnSeq(fromTurnSeq);
239
+ return {
240
+ text: `โ€ฆ ${n} earlier tool ${n === 1 ? 'line' : 'lines'} omitted`,
241
+ subagentRunId: null,
242
+ metadata: { kind: ACTIVITY_OMITTED_KIND, count: n, ...(from !== null ? { fromTurnSeq: from } : {}) },
243
+ };
244
+ }
245
+
246
+ export function isOmittedActivityMarker(item) {
247
+ return normalizeRelayActivityEntry(item)?.metadata?.kind === ACTIVITY_OMITTED_KIND;
248
+ }
249
+
250
+ // The finished turn's cap, as the relay sends it and the bubble reads it; a
251
+ // cut in the main agent's lines is marked.
252
+ export function capFinishedActivityEntries(items) {
253
+ return capRelayActivityEntries(items, FINISHED_ACTIVITY_LIMIT_PER_THREAD, {
254
+ subagentLimit: FINISHED_SUBAGENT_ACTIVITY_LIMIT_PER_THREAD,
255
+ omittedMarker: true,
256
+ });
152
257
  }
153
258
 
154
259
  export function relayActivityEntryText(item) {
155
260
  return normalizeRelayActivityEntry(item)?.text || '';
156
261
  }
157
262
 
158
- function normalizeActivityItems(items, limit = DEFAULT_ACTIVITY_LIMIT) {
263
+ // The live list's rule: the newest `cap` ordinary lines of each thread, the
264
+ // structured rows kept (capLiveActivityEntries); Infinity or 0 keeps all.
265
+ function normalizeActivityItems(items, cap = LIVE_ACTIVITY_CAP) {
159
266
  const normalized = Array.isArray(items)
160
267
  ? items.map((item) => normalizeRelayActivityEntry(item)).filter(Boolean)
161
268
  : [];
162
- return normalized.slice(-Math.max(1, Number(limit) || DEFAULT_ACTIVITY_LIMIT));
269
+ return capLiveActivityEntries(normalized, cap);
163
270
  }
164
271
 
272
+ // A numbered row is the row with that number (the same tool line twice is two
273
+ // rows; a replayed event is one); an unnumbered one is known by its text.
165
274
  function activityEntryKey(item) {
166
275
  const entry = normalizeRelayActivityEntry(item);
167
276
  if (!entry) return '';
277
+ if (entry.turnSeq !== undefined) return `#${entry.turnSeq}`;
168
278
  return `${entry.subagentRunId || ''}::${entry.text}`;
169
279
  }
170
280
 
281
+ const isNumbered = (item) => normalizeRelayActivityEntry(item)?.turnSeq !== undefined;
282
+
171
283
  function isSubsequence(subset, sequence) {
172
284
  if (!subset.length) return true;
173
285
  let cursor = 0;
@@ -178,11 +290,17 @@ function isSubsequence(subset, sequence) {
178
290
  return false;
179
291
  }
180
292
 
181
- export function mergeRelayActivityTexts(existingItems, incomingItems, limit = DEFAULT_ACTIVITY_LIMIT) {
293
+ export function mergeRelayActivityTexts(existingItems, incomingItems, limit = LIVE_ACTIVITY_CAP) {
182
294
  const existing = normalizeActivityItems(existingItems, Number.POSITIVE_INFINITY);
183
295
  const incoming = normalizeActivityItems(incomingItems, Number.POSITIVE_INFINITY);
184
296
  if (!existing.length) return normalizeActivityItems(incoming, limit);
185
297
  if (!incoming.length) return normalizeActivityItems(existing, limit);
298
+ // Both sides numbered (migration 0038): the union by number, in order.
299
+ if (existing.every(isNumbered) && incoming.every(isNumbered)) {
300
+ const byTurnSeq = new Map();
301
+ for (const item of [...existing, ...incoming]) byTurnSeq.set(item.turnSeq, item);
302
+ return normalizeActivityItems([...byTurnSeq.values()].sort((a, b) => a.turnSeq - b.turnSeq), limit);
303
+ }
186
304
  if (isSubsequence(existing, incoming)) return normalizeActivityItems(incoming, limit);
187
305
  if (isSubsequence(incoming, existing)) return normalizeActivityItems(existing, limit);
188
306
 
@@ -1,4 +1,5 @@
1
1
  import { BASE, TOKEN, CLIENT_ID, IS_SHARE_PREVIEW, SHARE_PREVIEW_CONVERSATION_ID, SHARED_CONVERSATION_TOKEN, authHeaders, updateWorkspaceRootHints, applyContextUsageBar, readContextUsageRatio, currentConvId, conversations, setCliOnline, setCloudflaredTunnelState, setActiveRuntimeSessionCount, setRuntimeSessionBindingCount, setContextIndicatorMode, setServerPlatform, setUpdateState } from './store.js';
2
+ import { liveActivityCap } from './live-activity-cap.mjs';
2
3
 
3
4
  // fetch() has no default timeout. Without one, a request issued while the app is
4
5
  // being backgrounded can hang indefinitely on a half-open mobile connection and
@@ -981,6 +982,12 @@ export async function loadConversation(id, options = {}) {
981
982
  if (afterTimestamp) params.set('afterTimestamp', afterTimestamp);
982
983
  const aroundMessageId = String(options.aroundMessageId || '').trim();
983
984
  if (aroundMessageId) params.set('aroundMessageId', aroundMessageId);
985
+ // The running turn's tool lines per agent this page shows ("Tool lines
986
+ // shown"; 0 = no limit): the relay sends no more of them.
987
+ params.set('activityCap', String(liveActivityCap()));
988
+ // The live poll's finished replies it already holds (held-details.mjs).
989
+ const heldDetails = String(options.heldDetails || '');
990
+ if (heldDetails) params.set('heldDetails', heldDetails);
984
991
  const query = params.toString();
985
992
  // `onErrorResponse` tells a conversation the relay does not know (404) from a failed load.
986
993
  const onErrorResponse = typeof options.onErrorResponse === 'function' ? options.onErrorResponse : undefined;
@@ -16,7 +16,7 @@ import { isChatInteractionHeld } from './selection-guard.mjs';
16
16
  import { questionCardHeadLabel } from './conversation-provider-indicator.mjs';
17
17
  import { questionAcceptsFreeText } from './question-answer-rule.mjs';
18
18
  import { beginOwnSend, followsTranscriptEnd, refreshJumpButton, scrollAfterOwnSend } from './transcript-jump-bottom-view.mjs';
19
- import { renderSessionAskCards } from './inbox-ui.js';
19
+ import { renderQuestionStubs } from './questions-panel-ui.js';
20
20
  import { isConversationAutoOpenHeld } from './session-history-ui.js';
21
21
 
22
22
  let relayQuestionRenderHash = '';
@@ -230,8 +230,9 @@ function scrollQuestionCardIntoMessages(el, target) {
230
230
  export function renderRelayQuestions() {
231
231
  const el = document.getElementById('messages');
232
232
  if (!el) return;
233
- // The questions that don't block go first: the cards a turn waits on stay last.
234
- renderSessionAskCards();
233
+ // The questions that don't block: their ๐Ÿ““ stubs, in their turns' bubbles
234
+ // or standing alone before the cards a turn waits on.
235
+ renderQuestionStubs();
235
236
  const previousScrollTop = el.scrollTop;
236
237
  // A card is shown to a reader who is at the end. Anyone further up stays
237
238
  // where they are; the jump-to-bottom button says that a question waits.
@@ -63,6 +63,9 @@ import {
63
63
  } from './transcript-viewport-anchor.mjs';
64
64
  import { initTranscriptJumpToBottom, onTranscriptEndLeft } from './transcript-jump-bottom-view.mjs';
65
65
  import { syncScrollOnSendToggle, updateScrollOnSendSetting } from './transcript-jump-bottom.mjs';
66
+ import { isThoughtsExpanded, syncThoughtsExpandedToggle, updateThoughtsExpandedSetting } from './thoughts-preference.mjs';
67
+ import { previewLiveActivityCap, syncLiveActivityCapSlider, updateLiveActivityCap } from './live-activity-cap.mjs';
68
+ import { STREAM_VIEW_CHANGE_EVENT, isStreamViewEnabled, syncStreamViewToggle, updateStreamViewSetting } from './stream-view-preference.mjs';
66
69
  import { previewWindowRetention, syncWindowRetentionSlider, updateWindowRetention } from './conversation-window-retention.mjs';
67
70
  import { conversationPayloadCache, mergeReloadIntoWindow } from './conversation-payload-cache.mjs';
68
71
  import {
@@ -93,7 +96,7 @@ import {
93
96
  reportSharedViewerPresence,
94
97
  setNetworkRequestsEnabled,
95
98
  } from './api-client.js';
96
- import { loadConversations, refreshConversations, openConversation, renderConvList, applyLoadedConversationState, initConversationListLazyLoading, initConversationFilter } from './journal-view.js';
99
+ import { loadConversations, refreshConversations, openConversation, renderConvList, applyLoadedConversationState, heldDetailsStore, initConversationListLazyLoading, initConversationFilter } from './journal-view.js';
97
100
  import {
98
101
  newConversation,
99
102
  confirmNewConversationModel,
@@ -150,6 +153,7 @@ import {
150
153
  getHeldBackLiveRowIds,
151
154
  captureConversationReadingAnchor,
152
155
  canMergeIntoTranscript,
156
+ refreshStreamViewLayout,
153
157
  } from './conversation-view.js';
154
158
  import { loadKeepingLiveRows } from './transcript-reload-freshness.mjs';
155
159
  import { backgroundTasksSnapshotMark } from './background-tasks-view.mjs';
@@ -250,6 +254,8 @@ import { closeAnnotateEditor } from './annotate-editor.js';
250
254
  import { closeEmojiPicker } from './emoji-view.js';
251
255
  import { closeChangeCwdMenu } from './cwd-picker.js';
252
256
  import { closeRelayWatchPanel } from './relay-event-subscriptions-ui.js';
257
+ import { closeQuestionsPanel, initQuestionsPanel, openQuestionsPanel, setQuestionsConversation } from './questions-panel-ui.js';
258
+ import { questionsTwinLabel } from './questions-view.mjs';
253
259
  import { closeRelaySchedulePanel } from './relay-schedules-ui.js';
254
260
  import { isInboxViewActive } from './inbox-ui.js';
255
261
  import { initScrollbarHover } from './scrollbar-hover.mjs';
@@ -261,6 +267,8 @@ import {
261
267
  renderAutoCompactNotesHtml,
262
268
  renderContextUsageHtml,
263
269
  renderThinkingControlHtml,
270
+ renderThoughtsPreferenceHtml,
271
+ renderStreamViewPreferenceHtml,
264
272
  thinkingEnabledFromKey,
265
273
  thinkingEnabledToKey,
266
274
  } from './context-usage-view.mjs';
@@ -3702,6 +3710,18 @@ function initThinkingControl() {
3702
3710
  if (!body || body.dataset.thinkingBound === '1') return;
3703
3711
  body.dataset.thinkingBound = '1';
3704
3712
  body.addEventListener('click', (event) => {
3713
+ // ๐Ÿงต Stream view and ๐Ÿ’ญ Thoughts: settings of this browser, nothing goes
3714
+ // to the relay. (The stream view's buttons share the thoughts' look.)
3715
+ const streamViewBtn = event.target?.closest?.('.ctx-stream-view-btn');
3716
+ if (streamViewBtn && body.contains(streamViewBtn)) {
3717
+ updateStreamViewSetting(streamViewBtn.dataset.streamView === 'on');
3718
+ return;
3719
+ }
3720
+ const thoughtsBtn = event.target?.closest?.('.ctx-thoughts-btn[data-thoughts-expanded]');
3721
+ if (thoughtsBtn && body.contains(thoughtsBtn)) {
3722
+ updateThoughtsExpandedSetting(thoughtsBtn.dataset.thoughtsExpanded !== 'off');
3723
+ return;
3724
+ }
3705
3725
  const btn = event.target?.closest?.('.ctx-thinking-btn');
3706
3726
  if (!btn || !body.contains(btn)) return;
3707
3727
  void applyThinkingSelection(btn.dataset.thinkingAxis, btn.dataset.thinkingValue);
@@ -3861,6 +3881,10 @@ async function loadContextSummaryAndRender(convId) {
3861
3881
  thinkingDisplay: payload.thinkingDisplay ?? null,
3862
3882
  })
3863
3883
  : '';
3884
+ // Every provider streams thoughts somewhere; the row mirrors the Settings
3885
+ // toggle and is this browser's own.
3886
+ const thoughtsHtml = renderThoughtsPreferenceHtml({ expanded: isThoughtsExpanded() })
3887
+ + renderStreamViewPreferenceHtml({ on: isStreamViewEnabled() });
3864
3888
  // What this folder's commits say, with the folder override: Claude (on
3865
3889
  // either billing), and a Claude Cloud chat that was started from a folder
3866
3890
  // (no path, no row).
@@ -3885,10 +3909,10 @@ async function loadContextSummaryAndRender(convId) {
3885
3909
  // The structured breakdown is the answer; the runtime's own text dump stays
3886
3910
  // available underneath for the details it carries that categories don't.
3887
3911
  const bodyHtml = usageHtml
3888
- ? `${usageHtml}${autoCompactHtml}${thinkingHtml}${attributionHtml}${agentSessionsHtml}${detailText
3912
+ ? `${usageHtml}${autoCompactHtml}${thinkingHtml}${thoughtsHtml}${attributionHtml}${agentSessionsHtml}${detailText
3889
3913
  ? `<details class="ctx-usage-raw"><summary>Raw details</summary><pre>${escHtml(detailText)}</pre></details>`
3890
3914
  : ''}`
3891
- : `${autoCompactHtml}${thinkingHtml}${attributionHtml}${agentSessionsHtml}<pre>${escHtml(detailText || 'No context data available.')}</pre>`;
3915
+ : `${autoCompactHtml}${thinkingHtml}${thoughtsHtml}${attributionHtml}${agentSessionsHtml}<pre>${escHtml(detailText || 'No context data available.')}</pre>`;
3892
3916
 
3893
3917
  renderSummaryModalContent({
3894
3918
  title: 'Context usage',
@@ -4130,12 +4154,17 @@ async function pollAuthenticatedCurrentConversationLive() {
4130
4154
  const requestLimit = Math.max(20, getConversationLoadedMessageCount() || 0, savedLoadedCount || 0);
4131
4155
  const tasksMark = backgroundTasksSnapshotMark(currentId);
4132
4156
  const jumpEpoch = getTranscriptJumpEpoch();
4157
+ // The finished replies this page already holds go out without their
4158
+ // tool lines and thoughts (held-details.mjs); the answer gets them back
4159
+ // from the store, or is dropped if it names one the store lacks.
4160
+ const heldDetails = heldDetailsStore.param(currentId);
4133
4161
  const { response, stale } = await loadKeepingLiveRows(
4134
- () => loadConversation(currentId, { limit: requestLimit }),
4162
+ () => loadConversation(currentId, { limit: requestLimit, heldDetails }),
4135
4163
  getTranscriptLiveRowEpoch,
4136
4164
  );
4137
4165
  if (!response) return;
4138
4166
  if (String(currentConvId || '').trim() !== currentId) return;
4167
+ if (!heldDetailsStore.restore(currentId, response)) return;
4139
4168
  // The user jumped into the history while this request was on its way: the
4140
4169
  // answer is the end of the conversation as it was asked for before that,
4141
4170
  // and would overwrite the window they jumped to. The next round asks again.
@@ -4155,6 +4184,7 @@ async function pollAuthenticatedCurrentConversationLive() {
4155
4184
  followLiveUpdates: preserveBottom,
4156
4185
  mergeFrom: merge?.mergeFrom || null,
4157
4186
  backgroundTasksAsOf: tasksMark,
4187
+ fromPoll: true,
4158
4188
  });
4159
4189
  // A merge does not scroll as the full render does: a reader at the end
4160
4190
  // is taken to the new end here.
@@ -4600,6 +4630,7 @@ function headerPriorityActionAvailability() {
4600
4630
  const fullscreen = document.getElementById('fullscreen-btn');
4601
4631
  return resolveHeaderActionAvailability({
4602
4632
  sharedMode: isSharedReaderMode(),
4633
+ questionCount: Number(document.getElementById('questions-btn')?.dataset.count || 0),
4603
4634
  pinCount: Number(pins?.dataset.count || 0),
4604
4635
  hiddenCount: Number(hiddenBtn?.dataset.count || 0),
4605
4636
  fullscreenAvailable: fullscreen?.dataset.headerAvailable === '1',
@@ -4676,6 +4707,8 @@ function headerFitSignature(header, availability) {
4676
4707
  banner?.className, banner?.hidden, banner?.textContent,
4677
4708
  document.getElementById('sidebar-toggle')?.hidden,
4678
4709
  document.getElementById('chat-actions-menu-btn')?.hidden,
4710
+ document.getElementById('questions-btn')?.dataset.count,
4711
+ document.getElementById('questions-btn')?.dataset.urgent,
4679
4712
  document.getElementById('pinned-messages-btn')?.dataset.count,
4680
4713
  document.getElementById('hidden-messages-btn')?.dataset.count,
4681
4714
  document.getElementById('fullscreen-btn')?.textContent,
@@ -4782,6 +4815,14 @@ function syncHeaderPriorityTwinLabels() {
4782
4815
  const count = Number(document.getElementById(buttonId)?.dataset.count || 0);
4783
4816
  twin.textContent = count ? `${icon} ${text} (${count})` : `${icon} ${text}`;
4784
4817
  };
4818
+ const questions = document.getElementById('questions-btn');
4819
+ const questionsTwin = document.getElementById('chat-menu-questions');
4820
+ if (questionsTwin) {
4821
+ questionsTwin.textContent = questionsTwinLabel({
4822
+ pending: Number(questions?.dataset.count || 0),
4823
+ urgent: Number(questions?.dataset.urgent || 0),
4824
+ });
4825
+ }
4785
4826
  label('pinned-messages-btn', 'chat-menu-pins', 'Pinned messages', '๐Ÿ“');
4786
4827
  label('hidden-messages-btn', 'chat-menu-hidden', 'Hidden from shared viewers', '๐Ÿ™ˆ');
4787
4828
  }
@@ -4812,6 +4853,11 @@ function scheduleHeaderPrioritySync({ force = false } = {}) {
4812
4853
  // syncChatTitleControls.
4813
4854
  function initHeaderPriorityActions({ sharedMode = false } = {}) {
4814
4855
  if (!sharedMode) {
4856
+ // ๐Ÿ““'s own button toggles the list under it (questions-panel-ui.js); the
4857
+ // twin opens it under ๏ธ™.
4858
+ bindHeaderPriorityAction('questions', () => {
4859
+ openQuestionsPanel({ anchor: document.getElementById('chat-actions-menu-btn') });
4860
+ }, { inline: false });
4815
4861
  bindHeaderPriorityAction('pins', () => openPinnedMessagesModal(), { inline: false });
4816
4862
  bindHeaderPriorityAction('hidden', () => openHiddenMessagesModal(), { inline: false });
4817
4863
  bindHeaderPriorityAction('search', () => openMessageSearchModal(), { inline: false });
@@ -5052,6 +5098,7 @@ function syncChatTitleControls() {
5052
5098
  if (!convId) {
5053
5099
  setPinsConversation(null);
5054
5100
  setHiddenMessagesConversation(null);
5101
+ setQuestionsConversation(null);
5055
5102
  }
5056
5103
  const conversation = convId ? (conversations[convId] || null) : null;
5057
5104
  const sdkSessionId = String(conversation?.sdkSessionId || '').trim();
@@ -5333,6 +5380,9 @@ async function initApp() {
5333
5380
  if (!sharedMode) {
5334
5381
  syncSuspendHostVisibility();
5335
5382
  syncScrollOnSendToggle();
5383
+ syncThoughtsExpandedToggle();
5384
+ syncLiveActivityCapSlider();
5385
+ syncStreamViewToggle();
5336
5386
  syncWindowRetentionSlider();
5337
5387
  initHostSuspendUi();
5338
5388
  initUsageLimitUi();
@@ -5734,6 +5784,7 @@ async function initApp() {
5734
5784
  void loadRelaySchedules();
5735
5785
  // The Inbox's count, and the cards of questions that don't block.
5736
5786
  startInbox();
5787
+ initQuestionsPanel();
5737
5788
  if (pushConversationId) {
5738
5789
  await openConversation(pushConversationId, { history: 'replace' }).catch(() => {});
5739
5790
  }
@@ -5812,6 +5863,7 @@ async function bootstrap() {
5812
5863
  closeTmuxInspector: closeTmuxInspectorView,
5813
5864
  closeAnnotateEditor,
5814
5865
  closeRelayWatchPanel,
5866
+ closeQuestionsPanel,
5815
5867
  closeRelaySchedulePanel,
5816
5868
  closeEmojiPicker,
5817
5869
  });
@@ -5900,6 +5952,12 @@ window.resetGrokAllowanceAccounting = resetGrokAllowanceAccounting;
5900
5952
  window.toggleCursorProvider = toggleCursorProvider;
5901
5953
  window.updateShowSuspendHostSetting = updateShowSuspendHostSetting;
5902
5954
  window.updateScrollOnSendSetting = (enabled) => updateScrollOnSendSetting(enabled);
5955
+ window.updateThoughtsExpandedSetting = (expanded) => updateThoughtsExpandedSetting(expanded);
5956
+ window.updateStreamViewSetting = (on) => updateStreamViewSetting(on);
5957
+ // The transcript and the live bubble take the other layout at once.
5958
+ window.addEventListener(STREAM_VIEW_CHANGE_EVENT, () => refreshStreamViewLayout());
5959
+ window.previewLiveActivityCap = (position) => previewLiveActivityCap(position);
5960
+ window.updateLiveActivityCap = (position) => updateLiveActivityCap(position);
5903
5961
  window.previewConversationWindowRetention = (position) => previewWindowRetention(position);
5904
5962
  // A lower cap cuts the kept windows down now; the open conversation's is cut
5905
5963
  // when it is left.
@@ -47,6 +47,7 @@ export const CHAT_ACTIONS_MENU_ENTRY_IDS = Object.freeze([
47
47
  'chat-menu-schedules',
48
48
  'chat-menu-origin',
49
49
  'chat-menu-edit-title',
50
+ 'chat-menu-questions',
50
51
  'chat-menu-pins',
51
52
  'chat-menu-hidden',
52
53
  'chat-menu-search',
@@ -82,6 +83,7 @@ const ALWAYS_HIDDEN_IDS = new Set(['chat-menu-compact', 'chat-menu-refresh-histo
82
83
  * with HEADER_PRIORITY_MENU_IDS by the tests.
83
84
  */
84
85
  export const CHAT_ACTIONS_MENU_TWIN_IDS = Object.freeze([
86
+ 'chat-menu-questions',
85
87
  'chat-menu-pins',
86
88
  'chat-menu-hidden',
87
89
  'chat-menu-search',
@@ -350,6 +350,74 @@ ${renderThinkingButtons('display', THINKING_DISPLAY_CHOICES, displayKey)}
350
350
  `;
351
351
  }
352
352
 
353
+ const THOUGHTS_EXPANDED_CHOICES = Object.freeze([
354
+ { key: 'on', label: 'Expanded' },
355
+ { key: 'off', label: 'Collapsed' },
356
+ ]);
357
+
358
+ /**
359
+ * The mirror of Settings โ€บ General "Expand thoughts while an agent works", for
360
+ * every provider. A setting of this browser, so nothing goes to the relay:
361
+ * the buttons write thoughts-preference.mjs and both places follow it.
362
+ *
363
+ * @returns {string} HTML
364
+ */
365
+ export function renderThoughtsPreferenceHtml({ expanded = true } = {}) {
366
+ const activeKey = expanded === false ? 'off' : 'on';
367
+ const buttons = THOUGHTS_EXPANDED_CHOICES.map(({ key, label }) => `
368
+ <button
369
+ type="button"
370
+ class="ctx-thoughts-btn${key === activeKey ? ' is-active' : ''}"
371
+ data-thoughts-expanded="${key}"
372
+ aria-pressed="${key === activeKey ? 'true' : 'false'}"
373
+ >${escapeHtml(label)}</button>`).join('');
374
+ return `
375
+ <div class="ctx-thinking ctx-thoughts">
376
+ <div class="ctx-thinking-row">
377
+ <span class="ctx-thinking-label">๐Ÿ’ญ Thoughts</span>
378
+ <div class="ctx-thinking-group" role="group" aria-label="Thoughts while an agent works">
379
+ ${buttons}
380
+ </div>
381
+ </div>
382
+ <div class="ctx-thinking-note">Whether thoughts open while an agent works, in sub-agent bubbles and in the sub-agent viewer. A thought you fold or open by hand stays that way. Saved in this browser.</div>
383
+ </div>
384
+ `;
385
+ }
386
+
387
+ const STREAM_VIEW_CHOICES = Object.freeze([
388
+ { key: 'on', label: 'On' },
389
+ { key: 'off', label: 'Off' },
390
+ ]);
391
+
392
+ /**
393
+ * The mirror of Settings โ€บ General "Stream view", for every provider. A
394
+ * setting of this browser (stream-view-preference.mjs); nothing goes to the
395
+ * relay.
396
+ *
397
+ * @returns {string} HTML
398
+ */
399
+ export function renderStreamViewPreferenceHtml({ on = false } = {}) {
400
+ const activeKey = on === true ? 'on' : 'off';
401
+ const buttons = STREAM_VIEW_CHOICES.map(({ key, label }) => `
402
+ <button
403
+ type="button"
404
+ class="ctx-thoughts-btn ctx-stream-view-btn${key === activeKey ? ' is-active' : ''}"
405
+ data-stream-view="${key}"
406
+ aria-pressed="${key === activeKey ? 'true' : 'false'}"
407
+ >${escapeHtml(label)}</button>`).join('');
408
+ return `
409
+ <div class="ctx-thinking ctx-stream-view">
410
+ <div class="ctx-thinking-row">
411
+ <span class="ctx-thinking-label">๐Ÿงต Stream view</span>
412
+ <div class="ctx-thinking-group" role="group" aria-label="Stream view">
413
+ ${buttons}
414
+ </div>
415
+ </div>
416
+ <div class="ctx-thinking-note">Thoughts, tool calls and the reply of a turn as one list in the order they happened. Turns recorded before this version keep the grouped layout. Saved in this browser.</div>
417
+ </div>
418
+ `;
419
+ }
420
+
353
421
  /**
354
422
  * The owner's switch for a session an agent started: may its agents start
355
423
  * sessions themselves (the relay_tool's one-level rule lifted for it)?