@oar-sh/oar 0.9.5 → 0.9.6

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 (63) hide show
  1. package/.github/extensions/web-relay/polling/polling-loop.mjs +12 -32
  2. package/.github/extensions/web-relay/skills/question-bridge.mjs +14 -39
  3. package/CHANGELOG.md +168 -0
  4. package/README.md +20 -4
  5. package/package.json +2 -1
  6. package/server/claude-worker/claude-session-process.mjs +118 -1
  7. package/server/claude-worker/claude-turn-failure.mjs +92 -0
  8. package/server/claude-worker/claude-turn-publisher.mjs +52 -3
  9. package/server/claude-worker/sdk-message-normalizer.mjs +13 -1
  10. package/server/copilot-worker/copilot-model-switch.mjs +29 -3
  11. package/server/copilot-worker/copilot-question-bridge.mjs +2 -4
  12. package/server/copilot-worker/copilot-runtime-tree.mjs +91 -0
  13. package/server/copilot-worker/copilot-sdk-adapter.mjs +48 -8
  14. package/server/copilot-worker/copilot-sdk-session-process.mjs +897 -166
  15. package/server/cursor-worker/cursor-sdk-adapter.mjs +80 -13
  16. package/server/cursor-worker/cursor-turn-runner.mjs +5 -2
  17. package/server/db-schema.mjs +7 -0
  18. package/server/grok-worker/acp-client.mjs +68 -12
  19. package/server/grok-worker/grok-sdk-adapter.mjs +4 -0
  20. package/server/grok-worker/grok-turn-runner.mjs +6 -2
  21. package/server/public/app/api-client.js +17 -2
  22. package/server/public/app/bootstrap.js +3 -0
  23. package/server/public/app/composer-control-state.mjs +4 -3
  24. package/server/public/app/conversation-view.js +95 -4
  25. package/server/public/app/journal-view.js +6 -0
  26. package/server/public/app/markdown-strikethrough.mjs +31 -0
  27. package/server/public/app/relay-board-inline.mjs +70 -0
  28. package/server/public/app/relay-board-view.js +157 -61
  29. package/server/public/app/relay-error-ctas.mjs +12 -3
  30. package/server/public/app/socket-handlers.js +9 -0
  31. package/server/public/app/store.js +4 -1
  32. package/server/public/app/subagent-fold.mjs +61 -0
  33. package/server/public/app/usage-limit-ui.js +225 -0
  34. package/server/public/index.html +90 -1
  35. package/server/repositories/message-repository.mjs +10 -1
  36. package/server/repositories/question-repository.mjs +1 -0
  37. package/server/repositories/remote-relay-repository.mjs +30 -0
  38. package/server/routes/messages-routes.mjs +85 -14
  39. package/server/routes/relay-board-routes.mjs +56 -2
  40. package/server/routes/sessions-routes.mjs +36 -0
  41. package/server/server-runtime.mjs +33 -2
  42. package/server/services/preview-proxy-server.mjs +64 -36
  43. package/server/services/process-list-service.mjs +122 -0
  44. package/server/services/relay-console-log.mjs +199 -0
  45. package/server/services/remote-relay-caller-context.mjs +37 -17
  46. package/server/services/remote-relay-dispatcher.mjs +72 -8
  47. package/server/services/remote-relay-inbound.mjs +35 -0
  48. package/server/services/session-worker-process-service.mjs +85 -32
  49. package/server/services/session-worker-registry-service.mjs +1 -1
  50. package/server/services/session-worker-supervisor-service.mjs +33 -1
  51. package/server/services/usage-limit-pause-service.mjs +441 -0
  52. package/shared/ask-user-bridge.mjs +14 -39
  53. package/shared/claude-usage-limit.mjs +257 -0
  54. package/shared/control-poller.mjs +69 -20
  55. package/shared/failure-note-text.mjs +38 -0
  56. package/shared/question-wait.mjs +108 -0
  57. package/shared/remote-relay-contract.mjs +16 -2
  58. package/shared/steer-settle-failure.mjs +16 -0
  59. package/shared/steer-settle-markers.mjs +8 -0
  60. package/shared/worker-runtime/process-tree.mjs +483 -0
  61. package/shared/worker-runtime/relay-errors.mjs +31 -0
  62. package/shared/worker-runtime/send-and-wait-errors.mjs +4 -4
  63. package/shared/worker-runtime/turn-liveness.mjs +249 -0
@@ -91,6 +91,7 @@ import {
91
91
  } from './activity-replay-state.mjs';
92
92
  import { SEPARATOR_CLASS, syncSeparatorRail, syncTranscriptSeparators } from './transcript-separators.mjs';
93
93
  import { ABSORBED_MSG_CLASS, STEER_MARKER_CLASS_BY_KIND, syncSteeredTurnMerge } from './steered-turn-merge.mjs';
94
+ import { resolveSubagentFold, subagentFoldSummary, toggledSubagentFoldChoice } from './subagent-fold.mjs';
94
95
  import { deriveComposerControlState, hasComposerDraft, hasUploadingAttachments } from './composer-control-state.mjs';
95
96
  import { buildLiveMessageFingerprint } from './live-message-dedupe.mjs';
96
97
  import { createInfiniteLoader } from './infinite-loader.js';
@@ -494,7 +495,7 @@ function cancellableSteerIdsForConversation(conversationId) {
494
495
  // and why. Two sources, freshest first: the client's own knowledge of an open
495
496
  // question card (instant — the worker's heartbeat snapshot lags up to ~10s),
496
497
  // then the worker-reported steering snapshot for the slower holds only it can
497
- // see (compaction, post-compaction adoption).
498
+ // see (compaction, post-compaction adoption, a running tool call).
498
499
  function steeringHoldForConversation(conversationId) {
499
500
  const conversationKey = String(conversationId || '').trim();
500
501
  if (!conversationKey || !conversationSupportsSteering(conversationKey)) {
@@ -1766,6 +1767,8 @@ export function showThinking(messageId = null, autoScroll = true) {
1766
1767
  anchorThinkingBubble(div, nextMessageId);
1767
1768
  renderThinkingThoughts();
1768
1769
  renderThinkingStream();
1770
+ // A board the turn has posted already lives in its live bubble.
1771
+ renderRelayBoards();
1769
1772
  observeThinkingBubble(div);
1770
1773
  if (getThinkingFollowMode()) applyThinkingFollow();
1771
1774
  else if (autoScroll) scrollBottom();
@@ -1885,6 +1888,7 @@ function renderSubagentStream(subagentRunId, text) {
1885
1888
  }
1886
1889
  box.hidden = false;
1887
1890
  patchRenderedMarkdown(box, renderMarkdownPreview(value, false));
1891
+ refreshSubagentSummary(bubble);
1888
1892
  }
1889
1893
 
1890
1894
  function patchActivityList(box, expectedTexts, className) {
@@ -1932,6 +1936,7 @@ export function renderThinkingActivities() {
1932
1936
  const runBox = bubble?.querySelector('.subagent-activity');
1933
1937
  if (runBox && !selectionIntersectsNode(runBox)) {
1934
1938
  patchActivityList(runBox, expected, 'subagent-activity-item');
1939
+ refreshSubagentSummary(bubble);
1935
1940
  }
1936
1941
  }
1937
1942
  }
@@ -2046,6 +2051,7 @@ export function appendThinkingActivity(item, subagentRunId = null, autoScroll =
2046
2051
  row.className = 'subagent-activity-item';
2047
2052
  row.textContent = decorated;
2048
2053
  activityBox.appendChild(row);
2054
+ refreshSubagentSummary(subagentBubble);
2049
2055
  }
2050
2056
  }
2051
2057
  if (autoScroll) scrollBottom();
@@ -2125,6 +2131,7 @@ function ensureSubagentBubble(subagentRunId, depth = 0) {
2125
2131
  const status = getSubagentStatus(id);
2126
2132
  updateSubagentBubbleStatus(bubble, status);
2127
2133
  updateSubagentStopButton(id, isSubagentCancelInFlight(id), status);
2134
+ applySubagentFold(id);
2128
2135
  return bubble;
2129
2136
  }
2130
2137
 
@@ -2136,8 +2143,19 @@ function ensureSubagentBubble(subagentRunId, depth = 0) {
2136
2143
  bubble.className = 'subagent-bubble';
2137
2144
  bubble.dataset.subagentRunId = id;
2138
2145
 
2146
+ // The whole header folds and unfolds the bubble (a phone needs the large
2147
+ // target); the Stop button inside it keeps its own click.
2139
2148
  const header = document.createElement('div');
2140
2149
  header.className = 'subagent-bubble-header';
2150
+ header.dataset.action = 'toggle-subagent';
2151
+ header.dataset.subagentRunId = id;
2152
+ header.setAttribute('role', 'button');
2153
+ header.tabIndex = 0;
2154
+
2155
+ const foldMark = document.createElement('span');
2156
+ foldMark.className = 'subagent-bubble-fold';
2157
+ foldMark.setAttribute('aria-hidden', 'true');
2158
+ header.appendChild(foldMark);
2141
2159
 
2142
2160
  const nameSpan = document.createElement('span');
2143
2161
  nameSpan.className = 'subagent-bubble-name';
@@ -2151,6 +2169,10 @@ function ensureSubagentBubble(subagentRunId, depth = 0) {
2151
2169
  ? '● Running'
2152
2170
  : normalizeSubagentBubbleStatus(status).charAt(0).toUpperCase() + normalizeSubagentBubbleStatus(status).slice(1);
2153
2171
 
2172
+ // What the run is doing, in one line, for the header of a folded bubble.
2173
+ const summarySpan = document.createElement('span');
2174
+ summarySpan.className = 'subagent-bubble-summary';
2175
+
2154
2176
  const controls = document.createElement('div');
2155
2177
  controls.className = 'subagent-bubble-controls';
2156
2178
  controls.appendChild(statusSpan);
@@ -2163,6 +2185,7 @@ function ensureSubagentBubble(subagentRunId, depth = 0) {
2163
2185
  controls.appendChild(stopBtn);
2164
2186
 
2165
2187
  header.appendChild(nameSpan);
2188
+ header.appendChild(summarySpan);
2166
2189
  header.appendChild(controls);
2167
2190
 
2168
2191
  const activityBox = document.createElement('div');
@@ -2176,6 +2199,7 @@ function ensureSubagentBubble(subagentRunId, depth = 0) {
2176
2199
  bubble.appendChild(activityBox);
2177
2200
  container.appendChild(bubble);
2178
2201
  updateSubagentStopButton(id, isSubagentCancelInFlight(id), status);
2202
+ applySubagentFold(id);
2179
2203
 
2180
2204
  const entry = getSubagentRun(id);
2181
2205
  if (entry?.thoughts?.length) {
@@ -2205,10 +2229,22 @@ function ensureSubagentBubble(subagentRunId, depth = 0) {
2205
2229
  if (entry?.streamText) {
2206
2230
  renderSubagentStream(id, entry.streamText);
2207
2231
  }
2232
+ refreshSubagentSummary(bubble);
2208
2233
 
2209
2234
  return bubble;
2210
2235
  }
2211
2236
 
2237
+ /** Bring the folded header's line up to date with what the bubble holds. */
2238
+ function refreshSubagentSummary(bubble) {
2239
+ const summary = bubble?.querySelector(':scope > .subagent-bubble-header > .subagent-bubble-summary');
2240
+ if (!summary) return;
2241
+ const text = subagentFoldSummary({
2242
+ activities: Array.from(bubble.querySelectorAll(':scope > .subagent-activity > .subagent-activity-item'), (row) => row.textContent || ''),
2243
+ streamText: bubble.querySelector(':scope > .subagent-stream')?.textContent || '',
2244
+ });
2245
+ if (summary.textContent !== text) summary.textContent = text;
2246
+ }
2247
+
2212
2248
  function updateSubagentBubbleStatus(bubble, status) {
2213
2249
  if (!bubble) return;
2214
2250
  const statusSpan = bubble.querySelector('.subagent-bubble-status');
@@ -2218,6 +2254,38 @@ function updateSubagentBubbleStatus(bubble, status) {
2218
2254
  statusSpan.textContent = normalized === 'running' ? '● Running' : normalized.charAt(0).toUpperCase() + normalized.slice(1);
2219
2255
  }
2220
2256
 
2257
+ // What the user chose by hand for a live subagent bubble ('open' | 'folded'),
2258
+ // by run id. Kept outside the DOM: the live bubble is rebuilt on a
2259
+ // conversation switch and on reload-restore, and the choice must survive it.
2260
+ const subagentFoldChoices = new Map();
2261
+ const MAX_SUBAGENT_FOLD_CHOICES = 500;
2262
+
2263
+ /** Show a live subagent bubble folded or open (see subagent-fold.mjs). */
2264
+ function applySubagentFold(subagentRunId) {
2265
+ const id = String(subagentRunId || '').trim();
2266
+ if (!id) return;
2267
+ const bubble = document.querySelector(`.subagent-bubble[data-subagent-run-id="${CSS.escape(id)}"]`);
2268
+ if (!bubble) return;
2269
+ const folded = resolveSubagentFold({ choice: subagentFoldChoices.get(id) || null });
2270
+ bubble.classList.toggle('folded', folded);
2271
+ const header = bubble.querySelector(':scope > .subagent-bubble-header');
2272
+ if (header) {
2273
+ header.setAttribute('aria-expanded', folded ? 'false' : 'true');
2274
+ header.title = folded ? 'Show what this subagent did' : 'Fold this subagent';
2275
+ }
2276
+ }
2277
+
2278
+ function toggleSubagentFold(subagentRunId) {
2279
+ const id = String(subagentRunId || '').trim();
2280
+ const bubble = id ? document.querySelector(`.subagent-bubble[data-subagent-run-id="${CSS.escape(id)}"]`) : null;
2281
+ if (!bubble) return;
2282
+ while (subagentFoldChoices.size >= MAX_SUBAGENT_FOLD_CHOICES) {
2283
+ subagentFoldChoices.delete(subagentFoldChoices.keys().next().value);
2284
+ }
2285
+ subagentFoldChoices.set(id, toggledSubagentFoldChoice(bubble.classList.contains('folded')));
2286
+ applySubagentFold(id);
2287
+ }
2288
+
2221
2289
  const subagentStopUnsupported = new Set();
2222
2290
 
2223
2291
  /** The provider answered "not supported": the control must not re-arm. */
@@ -2238,6 +2306,11 @@ function updateSubagentStopButton(subagentRunId, isStopping = false, statusOverr
2238
2306
  btn.disabled = true;
2239
2307
  return;
2240
2308
  }
2309
+ const status = normalizeSubagentBubbleStatus(statusOverride || getSubagentStatus(id));
2310
+ const terminal = isSubagentTerminalStatus(status);
2311
+ // Nothing is left to stop once the run has ended; a disabled button there
2312
+ // only took the room the name needs.
2313
+ btn.hidden = terminal;
2241
2314
  if (subagentStopUnsupported.has(id)) {
2242
2315
  btn.disabled = true;
2243
2316
  btn.textContent = 'Stop unavailable';
@@ -2245,8 +2318,6 @@ function updateSubagentStopButton(subagentRunId, isStopping = false, statusOverr
2245
2318
  btn.classList.remove('stopping');
2246
2319
  return;
2247
2320
  }
2248
- const status = normalizeSubagentBubbleStatus(statusOverride || getSubagentStatus(id));
2249
- const terminal = isSubagentTerminalStatus(status);
2250
2321
  const stopping = !!isStopping;
2251
2322
  btn.disabled = terminal || stopping;
2252
2323
  btn.textContent = stopping ? 'Stopping…' : 'Stop';
@@ -2265,6 +2336,7 @@ export function updateSubagentBubbleFromStatus(subagentRunId, status) {
2265
2336
  clearSubagentCancelInFlight(id);
2266
2337
  }
2267
2338
  updateSubagentStopButton(id, isSubagentCancelInFlight(id), status);
2339
+ applySubagentFold(id);
2268
2340
  }
2269
2341
 
2270
2342
  function renderSubagentBubbleRecursive(entry) {
@@ -2992,7 +3064,7 @@ function runRelayErrorCta(cta) {
2992
3064
  }
2993
3065
 
2994
3066
  function handleBubbleActionClick(event) {
2995
- const btn = event.target.closest('.bubble-action-btn, .msg-share-visibility-btn, .thinking-follow-btn');
3067
+ const btn = event.target.closest('.bubble-action-btn, .msg-share-visibility-btn, .thinking-follow-btn, .subagent-bubble-header');
2996
3068
  if (!btn) return;
2997
3069
  const action = btn.dataset.action;
2998
3070
  const messageId = btn.dataset.messageId;
@@ -3045,6 +3117,22 @@ function handleBubbleActionClick(event) {
3045
3117
  event.stopPropagation();
3046
3118
  void cancelSubagentByRunId(currentConvId, subagentRunId);
3047
3119
  }
3120
+
3121
+ if (action === 'toggle-subagent' && subagentRunId) {
3122
+ // A tap that ends a text selection inside the header is not a toggle.
3123
+ if (String(window.getSelection?.() || '').trim()) return;
3124
+ event.preventDefault();
3125
+ toggleSubagentFold(subagentRunId);
3126
+ }
3127
+ }
3128
+
3129
+ // Enter and Space on a focused subagent header, like the click.
3130
+ function handleBubbleActionKeydown(event) {
3131
+ if (event.key !== 'Enter' && event.key !== ' ') return;
3132
+ const header = event.target.closest?.('.subagent-bubble-header');
3133
+ if (!header || event.target !== header) return;
3134
+ event.preventDefault();
3135
+ toggleSubagentFold(header.dataset.subagentRunId);
3048
3136
  }
3049
3137
 
3050
3138
  // Clicking an inline-embedded image in a message bubble opens it in the file
@@ -3077,6 +3165,7 @@ export function initBubbleActionHandlers() {
3077
3165
  const messagesEl = document.getElementById('messages');
3078
3166
  if (!messagesEl) return;
3079
3167
  messagesEl.addEventListener('click', handleBubbleActionClick);
3168
+ messagesEl.addEventListener('keydown', handleBubbleActionKeydown);
3080
3169
  messagesEl.addEventListener('click', handleEmbeddedImageClick);
3081
3170
  initThinkingFollow({ getCurrentConversationId: () => currentConvId });
3082
3171
  }
@@ -3114,6 +3203,8 @@ export function appendMessage(msg, scroll = true, msgId = null, force = false, i
3114
3203
  }
3115
3204
  }
3116
3205
  if (isNewNode && insertedNode) {
3206
+ // A reply takes over the board its turn posted (relay-board-view).
3207
+ if (msg?.role === 'assistant') renderRelayBoards();
3117
3208
  syncSeparatorsNow();
3118
3209
  if (scroll) scrollBottom();
3119
3210
  }
@@ -38,6 +38,7 @@ import {
38
38
  } from './api-client.js';
39
39
  import { renderMessages, restoreInFlightThinking, focusConversationMessageById, flushConversationDraft, hydrateConversationDraft, beginConversationDraftSwitch } from './conversation-view.js';
40
40
  import { setBackgroundTasksConversation, setConversationBackgroundTasks } from './background-tasks-view.mjs';
41
+ import { setUsageLimitConversation } from './usage-limit-ui.js';
41
42
  import { mergeConversationPreviews } from './preview-cards.mjs';
42
43
  import { loadRelayQuestions, getPendingQuestionCountsByConversation } from './ask-user-view.js';
43
44
  import { loadRelayBoards } from './relay-board-view.js';
@@ -434,6 +435,11 @@ export function applyLoadedConversationState(id, response, {
434
435
  restoreInFlightThinking(response.inFlight || null, followLiveUpdates);
435
436
  setBackgroundTasksConversation(id);
436
437
  setConversationBackgroundTasks(id, response.backgroundTasks || []);
438
+ setUsageLimitConversation(id, {
439
+ // Absent from an older relay's payload: keep what the socket said.
440
+ pause: 'usageLimitPause' in response ? response.usageLimitPause : undefined,
441
+ providerType: response.runtimeSession?.providerType,
442
+ });
437
443
  // Merge, not replace: this payload only carries one conversation's previews.
438
444
  mergeConversationPreviews(id, response.previews || []);
439
445
  updateSessionPill(conversations[id], response.runtimeSession || null);
@@ -0,0 +1,31 @@
1
+ // Strikethrough needs two tildes.
2
+ //
3
+ // The Markdown library also strikes text between two SINGLE tildes. In prose
4
+ // a single tilde means "about" ("~35 s … ~7 min"), and two of them in one
5
+ // paragraph struck out everything in between. Only `~~text~~` strikes now;
6
+ // a single tilde is the character it is.
7
+
8
+ const DOUBLE_TILDE = /^~~(?=\S)([\s\S]*?\S)~~(?!~)/;
9
+
10
+ /**
11
+ * The `del` tokenizer for `marked.use({ tokenizer: … })`. Returns the token
12
+ * for `~~text~~`, and undefined for anything else: "no strikethrough here".
13
+ * (`false` would hand the text back to the library's own rule.)
14
+ */
15
+ export function strictStrikethroughTokenizer(src) {
16
+ const match = DOUBLE_TILDE.exec(String(src || ''));
17
+ if (!match) return undefined;
18
+ return {
19
+ type: 'del',
20
+ raw: match[0],
21
+ text: match[1],
22
+ tokens: this.lexer.inlineTokens(match[1]),
23
+ };
24
+ }
25
+
26
+ /** Installs the rule on a Markdown library; false when it cannot take one. */
27
+ export function useStrictStrikethrough(markdown) {
28
+ if (!markdown || typeof markdown.use !== 'function') return false;
29
+ markdown.use({ tokenizer: { del: strictStrikethroughTokenizer } });
30
+ return true;
31
+ }
@@ -0,0 +1,70 @@
1
+ // A plan board inside the reply it belongs to.
2
+ //
3
+ // A board used to be a card of its own at the end of the transcript, below
4
+ // whatever was said after it, and it vanished once an action was chosen. It
5
+ // belongs to one turn: its content reads as part of that turn's reply, and its
6
+ // buttons are what the reply asks for. So it is shown inside the reply's
7
+ // bubble (inside the live bubble while the turn still runs), it stays there
8
+ // after the choice, and it says what was chosen.
9
+ //
10
+ // The reply often IS the plan: the worker posts the reply's own text as the
11
+ // board when the agent wrote its plan as the answer. Then the text is shown
12
+ // once, as the reply, and the board adds only its buttons.
13
+
14
+ function plain(text) {
15
+ return String(text || '').replace(/\s+/g, ' ').trim().toLowerCase();
16
+ }
17
+
18
+ /**
19
+ * Whether the reply already says what the board says. Both are compared as
20
+ * the text a reader sees (rendered, whitespace collapsed), so markdown that
21
+ * renders the same counts as the same.
22
+ */
23
+ export function boardBodyShownInReply(bodyText, replyText) {
24
+ const body = plain(bodyText);
25
+ if (!body) return true;
26
+ return plain(replyText).includes(body);
27
+ }
28
+
29
+ /** The label of the action that was chosen, as the button said it. */
30
+ export function chosenActionLabel(board) {
31
+ const chosen = String(board?.selectedAction || '').trim().toLowerCase();
32
+ if (!chosen) return '';
33
+ const actions = Array.isArray(board?.actions) ? board.actions : [];
34
+ const action = actions.find((item) => String(item?.id || '').trim().toLowerCase() === chosen);
35
+ return String(action?.label || '').trim() || chosen.replace(/[_-]+/g, ' ');
36
+ }
37
+
38
+ /** The line that replaces the buttons once the board is settled; '' while it is pending. */
39
+ export function boardOutcomeLine(board) {
40
+ const status = String(board?.status || 'pending').trim().toLowerCase();
41
+ if (status === 'pending') return '';
42
+ const label = chosenActionLabel(board);
43
+ if (label) return `Chosen: ${label}`;
44
+ return status === 'dismissed' ? 'Dismissed' : 'Closed';
45
+ }
46
+
47
+ /** The boards of one conversation, oldest first, each id once. */
48
+ export function boardsOfConversation(boards, conversationId) {
49
+ const id = String(conversationId || '').trim();
50
+ if (!id) return [];
51
+ const seen = new Set();
52
+ return Array.from(boards || [])
53
+ .filter((board) => board && board.id && board.conversationId === id)
54
+ .filter((board) => (seen.has(board.id) ? false : seen.add(board.id)))
55
+ .sort((a, b) => new Date(a.createdAt || 0) - new Date(b.createdAt || 0));
56
+ }
57
+
58
+ /** What a rendered board depends on: a change in any of it redraws the board. */
59
+ export function boardRenderKey(board, { duplicate = false } = {}) {
60
+ return JSON.stringify([
61
+ board?.id || '',
62
+ board?.status || '',
63
+ board?.selectedAction || '',
64
+ board?.title || '',
65
+ board?.body || '',
66
+ (Array.isArray(board?.actions) ? board.actions : []).map((action) => `${action?.id || ''}:${action?.label || ''}`),
67
+ board?.recommendedAction || '',
68
+ duplicate,
69
+ ]);
70
+ }
@@ -1,9 +1,15 @@
1
1
  import {
2
+ IS_SHARED_VIEW,
2
3
  currentConvId,
3
4
  escHtml,
4
- fmtDate,
5
5
  relayBoards,
6
6
  } from './store.js';
7
+ import {
8
+ boardBodyShownInReply,
9
+ boardOutcomeLine,
10
+ boardRenderKey,
11
+ boardsOfConversation,
12
+ } from './relay-board-inline.mjs';
7
13
  import {
8
14
  loadRelayBoards as loadRelayBoardsApi,
9
15
  submitRelayBoardAction as submitRelayBoardActionApi,
@@ -11,7 +17,10 @@ import {
11
17
  import { renderMarkdownPreview } from './router.js';
12
18
  import { attachCodeCopyButtons } from './code-copy.mjs';
13
19
 
14
- let relayBoardRenderHash = '';
20
+ // The conversation whose settled boards (acted on, dismissed) are loaded. They
21
+ // are asked for per conversation: only the pending ones matter everywhere.
22
+ let boardHistoryLoadedFor = '';
23
+ let boardHistoryLoading = '';
15
24
 
16
25
  export function upsertRelayBoard(board) {
17
26
  if (!board || !board.id) return;
@@ -24,91 +33,178 @@ export async function loadRelayBoards() {
24
33
  const pendingRes = await loadRelayBoardsApi('pending');
25
34
  if (!pendingRes) return;
26
35
  const pending = Array.isArray(pendingRes?.boards) ? pendingRes.boards.filter((board) => board && board.id) : [];
27
- relayBoards.clear();
36
+ // The settled boards of the conversation on screen stay: they are part of
37
+ // its transcript, and this list does not carry them.
38
+ for (const [id, board] of relayBoards.entries()) {
39
+ if (board?.status === 'pending' || board?.conversationId !== currentConvId) relayBoards.delete(id);
40
+ }
28
41
  for (const board of pending) relayBoards.set(board.id, board);
29
42
  window.renderConvList?.();
30
43
  renderRelayBoards();
31
44
  }
32
45
 
46
+ async function loadBoardHistory(conversationId) {
47
+ if (IS_SHARED_VIEW || !conversationId || boardHistoryLoading === conversationId) return;
48
+ boardHistoryLoading = conversationId;
49
+ try {
50
+ const res = await loadRelayBoardsApi('all', conversationId);
51
+ if (!res || currentConvId !== conversationId) return;
52
+ for (const board of Array.isArray(res.boards) ? res.boards : []) {
53
+ if (board && board.id) relayBoards.set(board.id, board);
54
+ }
55
+ boardHistoryLoadedFor = conversationId;
56
+ renderRelayBoards();
57
+ } finally {
58
+ if (boardHistoryLoading === conversationId) boardHistoryLoading = '';
59
+ }
60
+ }
61
+
62
+ /** Where a board is shown: its turn's reply, the live bubble of its turn, or nowhere yet. */
63
+ function findBoardHost(el, board) {
64
+ const source = String(board?.messageId || board?.queueId || '').trim();
65
+ if (!source) return null;
66
+ const replies = el.querySelectorAll(`.msg.assistant[data-source-message-id="${CSS.escape(source)}"]`);
67
+ // The turn's own reply is the last row that answers the message; a steer's
68
+ // marker above it is not a reply.
69
+ for (let index = replies.length - 1; index >= 0; index -= 1) {
70
+ const bubble = replies[index].querySelector(':scope > .msg-bubble');
71
+ if (bubble && !replies[index].classList.contains('relay-board-container')) return { bubble, live: false };
72
+ }
73
+ const live = document.getElementById('thinking-indicator');
74
+ if (live && String(live.dataset.messageId || '') === source) {
75
+ const bubble = live.querySelector(':scope > .thinking-bubble');
76
+ if (bubble) return { bubble, live: true };
77
+ }
78
+ return null;
79
+ }
80
+
81
+ function renderedText(html) {
82
+ const probe = document.createElement('div');
83
+ probe.innerHTML = html;
84
+ return probe.textContent || '';
85
+ }
86
+
87
+ /** What the bubble says without the board in it. */
88
+ function bubbleTextWithoutBoards(bubble) {
89
+ const copy = bubble.cloneNode(true);
90
+ copy.querySelectorAll('.relay-board-inline').forEach((node) => node.remove());
91
+ return copy.textContent || '';
92
+ }
93
+
94
+ function buildBoardSection(board, { duplicate, standalone = false }) {
95
+ const section = document.createElement('div');
96
+ section.className = `relay-board-inline${standalone ? ' relay-board-card' : ''}`;
97
+ section.dataset.boardId = board.id;
98
+ section.dataset.boardKey = boardRenderKey(board, { duplicate });
99
+
100
+ const title = String(board.title || 'Plan ready for review').trim();
101
+ const parts = [];
102
+ if (!duplicate) {
103
+ parts.push(`<div class="relay-board-head">${escHtml(title)}</div>`);
104
+ parts.push(`<div class="relay-board-body">${renderMarkdownPreview(board.body || '', false)}</div>`);
105
+ }
106
+ const actions = Array.isArray(board.actions) ? board.actions : [];
107
+ const outcome = boardOutcomeLine(board);
108
+ if (outcome) {
109
+ parts.push(`<div class="relay-board-outcome">${escHtml(outcome)}</div>`);
110
+ } else if (actions.length && !IS_SHARED_VIEW) {
111
+ const recommendedAction = String(board.recommendedAction || '').trim().toLowerCase();
112
+ parts.push(`<div class="relay-board-actions">${
113
+ actions.map((action) => {
114
+ const actionId = String(action?.id || '').trim().toLowerCase();
115
+ if (!actionId) return '';
116
+ const actionLabel = String(action?.label || actionId).trim();
117
+ const isRecommended = recommendedAction && actionId === recommendedAction;
118
+ return `<button type="button" class="relay-board-action${isRecommended ? ' relay-board-action-recommended' : ''}" data-action-id="${escHtml(actionId)}" onclick="submitRelayBoardAction('${escHtml(board.id)}', this.dataset.actionId)">${escHtml(actionLabel)}</button>`;
119
+ }).join('')
120
+ }</div>`);
121
+ }
122
+ section.innerHTML = parts.join('');
123
+ section.querySelectorAll('pre code').forEach((node) => hljs.highlightElement(node));
124
+ attachCodeCopyButtons(section);
125
+ return section;
126
+ }
127
+
128
+ /** Below what the reply says, above its attachments, tool lines and subagents. */
129
+ function placeBoardSection(bubble, section, { live }) {
130
+ const before = live
131
+ ? bubble.querySelector(':scope > .dots, :scope > #thinking-activity, :scope > .subagent-bubbles-container')
132
+ : bubble.querySelector(':scope > .msg-attachments, :scope > .msg-activity, :scope > .msg-workflow-runs, :scope > .msg-preview-cards, :scope > .msg-bubble-actions');
133
+ if (before) bubble.insertBefore(section, before);
134
+ else bubble.appendChild(section);
135
+ }
136
+
137
+ /**
138
+ * Put every board of the conversation on screen where it belongs. Runs after
139
+ * every render of the transcript, which rebuilds the bubbles: a board that is
140
+ * in place and unchanged is left alone, so a selection inside it survives.
141
+ */
33
142
  export function renderRelayBoards() {
34
143
  const el = document.getElementById('messages');
35
144
  if (!el) return;
36
- const boards = Array.from(relayBoards.values())
37
- .filter((board) => board && board.conversationId === currentConvId && board.status === 'pending')
38
- .sort((a, b) => new Date(a.createdAt || 0) - new Date(b.createdAt || 0));
39
-
40
- const nextHash = JSON.stringify(
41
- boards.map((board) => ({
42
- id: board.id,
43
- status: board.status,
44
- title: board.title || '',
45
- body: board.body || '',
46
- createdAt: board.createdAt || '',
47
- actions: Array.isArray(board.actions) ? board.actions.map((action) => action?.id || '') : [],
48
- recommendedAction: board.recommendedAction || '',
49
- })),
50
- );
51
- const existingCards = el.querySelectorAll('.relay-board-container');
52
- if (relayBoardRenderHash === nextHash && existingCards.length === boards.length) return;
53
- relayBoardRenderHash = nextHash;
54
-
55
- existingCards.forEach((node) => node.remove());
56
- if (!boards.length) return;
145
+ if (currentConvId && boardHistoryLoadedFor !== currentConvId) void loadBoardHistory(currentConvId).catch(() => {});
146
+ const boards = boardsOfConversation(relayBoards.values(), currentConvId);
147
+ const placed = new Set();
148
+ let added = false;
57
149
 
58
150
  for (const board of boards) {
59
- const wrapper = document.createElement('div');
60
- wrapper.className = 'msg relay-board-container';
61
- wrapper.dataset.boardId = board.id;
62
-
63
- const modeTag = board.mode ? ` <span class="msg-mode">${escHtml(board.mode)}</span>` : '';
64
- const boardType = String(board.boardType || 'board').trim().replace(/[_-]+/g, ' ');
65
- const title = String(board.title || 'Plan ready for review').trim();
66
- const bodyHtml = renderMarkdownPreview(board.body || '', false);
67
- const actions = Array.isArray(board.actions) ? board.actions : [];
68
- const recommendedAction = String(board.recommendedAction || '').trim().toLowerCase();
69
- const actionHtml = actions.length
70
- ? `<div class="relay-board-actions">${
71
- actions.map((action) => {
72
- const actionId = String(action?.id || '').trim().toLowerCase();
73
- if (!actionId) return '';
74
- const actionLabel = String(action?.label || actionId).trim();
75
- const isRecommended = recommendedAction && actionId === recommendedAction;
76
- return `<button class="relay-board-action${isRecommended ? ' relay-board-action-recommended' : ''}" data-action-id="${escHtml(actionId)}" onclick="submitRelayBoardAction('${board.id}', this.dataset.actionId)">${escHtml(actionLabel)}</button>`;
77
- }).join('')
78
- }</div>`
79
- : '';
80
-
81
- wrapper.innerHTML = `
82
- <div class="relay-board-card">
83
- <div class="relay-board-head">${escHtml(title)}${modeTag} · ${escHtml(boardType)} · ${fmtDate(board.createdAt)}</div>
84
- <div class="relay-board-body">${bodyHtml}</div>
85
- ${actionHtml}
86
- </div>`;
87
- wrapper.querySelectorAll('pre code').forEach((node) => hljs.highlightElement(node));
88
- attachCodeCopyButtons(wrapper);
89
- el.appendChild(wrapper);
151
+ const host = findBoardHost(el, board);
152
+ const pending = board.status === 'pending';
153
+ let target = host?.bubble || null;
154
+ let standalone = false;
155
+ if (!target) {
156
+ // No bubble to live in (its turn is further up than the page has
157
+ // loaded): a pending board still has to be answerable.
158
+ if (!pending) continue;
159
+ let wrapper = el.querySelector(`.relay-board-container[data-board-id="${CSS.escape(board.id)}"]`);
160
+ if (!wrapper) {
161
+ wrapper = document.createElement('div');
162
+ wrapper.className = 'msg relay-board-container';
163
+ wrapper.dataset.boardId = board.id;
164
+ el.appendChild(wrapper);
165
+ }
166
+ target = wrapper;
167
+ standalone = true;
168
+ }
169
+ const duplicate = !standalone && boardBodyShownInReply(
170
+ renderedText(renderMarkdownPreview(board.body || '', false)),
171
+ bubbleTextWithoutBoards(target),
172
+ );
173
+ const key = boardRenderKey(board, { duplicate });
174
+ const existing = target.querySelector(`:scope > .relay-board-inline[data-board-id="${CSS.escape(board.id)}"]`);
175
+ placed.add(existing && existing.dataset.boardKey === key ? existing : null);
176
+ if (existing && existing.dataset.boardKey === key) continue;
177
+ const section = buildBoardSection(board, { duplicate, standalone });
178
+ if (existing) existing.replaceWith(section);
179
+ else if (standalone) target.appendChild(section);
180
+ else placeBoardSection(target, section, { live: host.live });
181
+ placed.add(section);
182
+ added = added || pending;
90
183
  }
91
- window.scrollBottom?.();
184
+
185
+ // What is on screen and no longer belongs there: a board that moved from
186
+ // the live bubble into the reply, or one of another conversation.
187
+ el.querySelectorAll('.relay-board-inline').forEach((node) => { if (!placed.has(node)) node.remove(); });
188
+ el.querySelectorAll('.relay-board-container').forEach((node) => { if (!node.querySelector('.relay-board-inline')) node.remove(); });
189
+ if (added) window.scrollBottom?.();
92
190
  }
93
191
 
94
192
  export async function submitRelayBoardAction(boardId, actionId) {
95
193
  const id = String(boardId || '').trim();
96
194
  const nextActionId = String(actionId || '').trim();
97
195
  if (!id || !nextActionId) return;
98
- const card = document.querySelector(`.relay-board-container[data-board-id="${id}"]`);
196
+ const card = document.querySelector(`.relay-board-inline[data-board-id="${CSS.escape(id)}"]`);
99
197
  const controls = card ? card.querySelectorAll('button') : [];
100
198
  controls.forEach((control) => { control.disabled = true; });
101
199
 
102
200
  try {
103
201
  const r = await submitRelayBoardActionApi(id, nextActionId);
104
202
  if (!r?.board) throw new Error('Failed to submit board action');
203
+ // Kept once it is settled: the reply goes on showing the plan and says
204
+ // what was chosen.
105
205
  relayBoards.set(id, r.board);
106
- if (String(r.board.status || '').toLowerCase() !== 'pending') {
107
- relayBoards.delete(id);
108
- }
109
206
  window.renderConvList?.();
110
207
  renderRelayBoards();
111
- window.showTransientRelayNotice?.(`✅ Action selected: ${nextActionId}`);
112
208
  } catch (error) {
113
209
  controls.forEach((control) => { control.disabled = false; });
114
210
  alert(error.message || 'Failed to submit board action');
@@ -13,6 +13,10 @@
13
13
  // Codes are matched post-normalisation: normalizeTerminalErrorCode() lowercases
14
14
  // and rewrites every non-alphanumeric run to a dash, so `grok.cli_missing`
15
15
  // reaches the transcript as `relay.grok-cli-missing`.
16
+ const CLAUDE_SETTINGS_CTA = Object.freeze([
17
+ Object.freeze({ action: 'open-claude-settings', label: 'Claude settings' }),
18
+ ]);
19
+
16
20
  const RELAY_ERROR_CTAS = Object.freeze({
17
21
  'grok-cli-missing': Object.freeze([
18
22
  Object.freeze({ action: 'install-grok-cli', label: 'Install Grok CLI' }),
@@ -24,9 +28,14 @@ const RELAY_ERROR_CTAS = Object.freeze({
24
28
  ]),
25
29
  // Shipped with the Claude relogin plan (§4.3 there) as the deep link its
26
30
  // reworded message points at.
27
- 'claude-authentication-failed': Object.freeze([
28
- Object.freeze({ action: 'open-claude-settings', label: 'Claude settings' }),
29
- ]),
31
+ 'claude-authentication-failed': CLAUDE_SETTINGS_CTA,
32
+ // A turn the Claude account refused (claude-turn-failure.mjs): the panel
33
+ // shows the account and signs in to another one.
34
+ 'claude-billing-error': CLAUDE_SETTINGS_CTA,
35
+ 'claude-oauth-org-not-allowed': CLAUDE_SETTINGS_CTA,
36
+ 'claude-account-on-hold': CLAUDE_SETTINGS_CTA,
37
+ 'claude-verification-required': CLAUDE_SETTINGS_CTA,
38
+ 'claude-account-access': CLAUDE_SETTINGS_CTA,
30
39
  });
31
40
 
32
41
  const STABLE_CODE_PATTERN = /error code:\s*relay\.([a-z0-9-]+)/i;