@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.
- package/.github/extensions/web-relay/polling/polling-loop.mjs +12 -32
- package/.github/extensions/web-relay/skills/question-bridge.mjs +14 -39
- package/CHANGELOG.md +168 -0
- package/README.md +20 -4
- package/package.json +2 -1
- package/server/claude-worker/claude-session-process.mjs +118 -1
- package/server/claude-worker/claude-turn-failure.mjs +92 -0
- package/server/claude-worker/claude-turn-publisher.mjs +52 -3
- package/server/claude-worker/sdk-message-normalizer.mjs +13 -1
- package/server/copilot-worker/copilot-model-switch.mjs +29 -3
- package/server/copilot-worker/copilot-question-bridge.mjs +2 -4
- package/server/copilot-worker/copilot-runtime-tree.mjs +91 -0
- package/server/copilot-worker/copilot-sdk-adapter.mjs +48 -8
- package/server/copilot-worker/copilot-sdk-session-process.mjs +897 -166
- package/server/cursor-worker/cursor-sdk-adapter.mjs +80 -13
- package/server/cursor-worker/cursor-turn-runner.mjs +5 -2
- package/server/db-schema.mjs +7 -0
- package/server/grok-worker/acp-client.mjs +68 -12
- package/server/grok-worker/grok-sdk-adapter.mjs +4 -0
- package/server/grok-worker/grok-turn-runner.mjs +6 -2
- package/server/public/app/api-client.js +17 -2
- package/server/public/app/bootstrap.js +3 -0
- package/server/public/app/composer-control-state.mjs +4 -3
- package/server/public/app/conversation-view.js +95 -4
- package/server/public/app/journal-view.js +6 -0
- package/server/public/app/markdown-strikethrough.mjs +31 -0
- package/server/public/app/relay-board-inline.mjs +70 -0
- package/server/public/app/relay-board-view.js +157 -61
- package/server/public/app/relay-error-ctas.mjs +12 -3
- package/server/public/app/socket-handlers.js +9 -0
- package/server/public/app/store.js +4 -1
- package/server/public/app/subagent-fold.mjs +61 -0
- package/server/public/app/usage-limit-ui.js +225 -0
- package/server/public/index.html +90 -1
- package/server/repositories/message-repository.mjs +10 -1
- package/server/repositories/question-repository.mjs +1 -0
- package/server/repositories/remote-relay-repository.mjs +30 -0
- package/server/routes/messages-routes.mjs +85 -14
- package/server/routes/relay-board-routes.mjs +56 -2
- package/server/routes/sessions-routes.mjs +36 -0
- package/server/server-runtime.mjs +33 -2
- package/server/services/preview-proxy-server.mjs +64 -36
- package/server/services/process-list-service.mjs +122 -0
- package/server/services/relay-console-log.mjs +199 -0
- package/server/services/remote-relay-caller-context.mjs +37 -17
- package/server/services/remote-relay-dispatcher.mjs +72 -8
- package/server/services/remote-relay-inbound.mjs +35 -0
- package/server/services/session-worker-process-service.mjs +85 -32
- package/server/services/session-worker-registry-service.mjs +1 -1
- package/server/services/session-worker-supervisor-service.mjs +33 -1
- package/server/services/usage-limit-pause-service.mjs +441 -0
- package/shared/ask-user-bridge.mjs +14 -39
- package/shared/claude-usage-limit.mjs +257 -0
- package/shared/control-poller.mjs +69 -20
- package/shared/failure-note-text.mjs +38 -0
- package/shared/question-wait.mjs +108 -0
- package/shared/remote-relay-contract.mjs +16 -2
- package/shared/steer-settle-failure.mjs +16 -0
- package/shared/steer-settle-markers.mjs +8 -0
- package/shared/worker-runtime/process-tree.mjs +483 -0
- package/shared/worker-runtime/relay-errors.mjs +31 -0
- package/shared/worker-runtime/send-and-wait-errors.mjs +4 -4
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
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-
|
|
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':
|
|
28
|
-
|
|
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;
|