@oar-sh/oar 0.9.4 → 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 +199 -0
- package/README.md +35 -6
- package/package.json +2 -1
- package/server/claude-worker/claude-remote-relay-tool.mjs +1 -0
- package/server/claude-worker/claude-session-process.mjs +411 -19
- package/server/claude-worker/claude-session-worker.mjs +5 -0
- 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/copilot-worker/copilot-sdk-session-worker.mjs +5 -0
- package/server/cursor-worker/cursor-sdk-adapter.mjs +80 -13
- package/server/cursor-worker/cursor-session-worker.mjs +5 -0
- 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-session-worker.mjs +5 -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 +97 -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/steered-turn-merge.mjs +10 -1
- 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 +97 -4
- 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 +93 -17
- package/server/routes/relay-board-routes.mjs +56 -2
- package/server/routes/sessions-routes.mjs +40 -2
- 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 +45 -20
- package/server/services/remote-relay-dispatcher.mjs +328 -21
- package/server/services/remote-relay-inbound.mjs +35 -0
- package/server/services/session-worker-launch-service.mjs +17 -2
- 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 +33 -2
- package/shared/steer-settle-failure.mjs +22 -0
- package/shared/steer-settle-markers.mjs +24 -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
- package/shared/worker-runtime/worker-log-file.mjs +97 -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)) {
|
|
@@ -1277,6 +1278,8 @@ function createMessageNode(msg, msgId = null, force = false) {
|
|
|
1277
1278
|
// The settle marker of a steer the CLI consumed without a turn of its own:
|
|
1278
1279
|
// folded into (and answered by) the turn above, or cut off unanswered by a
|
|
1279
1280
|
// Stop. Compact, attached to its own user message, never merging forward.
|
|
1281
|
+
// Also the marker of a message answered on a continuation row, which only
|
|
1282
|
+
// shares the look.
|
|
1280
1283
|
const steerMarkerClass = msg.role === 'assistant' ? STEER_MARKER_CLASS_BY_KIND[messageKind] : null;
|
|
1281
1284
|
if (steerMarkerClass) div.classList.add(steerMarkerClass);
|
|
1282
1285
|
const isStoppedSteer = msg.role === 'assistant' && messageKind === 'stopped';
|
|
@@ -1764,6 +1767,8 @@ export function showThinking(messageId = null, autoScroll = true) {
|
|
|
1764
1767
|
anchorThinkingBubble(div, nextMessageId);
|
|
1765
1768
|
renderThinkingThoughts();
|
|
1766
1769
|
renderThinkingStream();
|
|
1770
|
+
// A board the turn has posted already lives in its live bubble.
|
|
1771
|
+
renderRelayBoards();
|
|
1767
1772
|
observeThinkingBubble(div);
|
|
1768
1773
|
if (getThinkingFollowMode()) applyThinkingFollow();
|
|
1769
1774
|
else if (autoScroll) scrollBottom();
|
|
@@ -1883,6 +1888,7 @@ function renderSubagentStream(subagentRunId, text) {
|
|
|
1883
1888
|
}
|
|
1884
1889
|
box.hidden = false;
|
|
1885
1890
|
patchRenderedMarkdown(box, renderMarkdownPreview(value, false));
|
|
1891
|
+
refreshSubagentSummary(bubble);
|
|
1886
1892
|
}
|
|
1887
1893
|
|
|
1888
1894
|
function patchActivityList(box, expectedTexts, className) {
|
|
@@ -1930,6 +1936,7 @@ export function renderThinkingActivities() {
|
|
|
1930
1936
|
const runBox = bubble?.querySelector('.subagent-activity');
|
|
1931
1937
|
if (runBox && !selectionIntersectsNode(runBox)) {
|
|
1932
1938
|
patchActivityList(runBox, expected, 'subagent-activity-item');
|
|
1939
|
+
refreshSubagentSummary(bubble);
|
|
1933
1940
|
}
|
|
1934
1941
|
}
|
|
1935
1942
|
}
|
|
@@ -2044,6 +2051,7 @@ export function appendThinkingActivity(item, subagentRunId = null, autoScroll =
|
|
|
2044
2051
|
row.className = 'subagent-activity-item';
|
|
2045
2052
|
row.textContent = decorated;
|
|
2046
2053
|
activityBox.appendChild(row);
|
|
2054
|
+
refreshSubagentSummary(subagentBubble);
|
|
2047
2055
|
}
|
|
2048
2056
|
}
|
|
2049
2057
|
if (autoScroll) scrollBottom();
|
|
@@ -2123,6 +2131,7 @@ function ensureSubagentBubble(subagentRunId, depth = 0) {
|
|
|
2123
2131
|
const status = getSubagentStatus(id);
|
|
2124
2132
|
updateSubagentBubbleStatus(bubble, status);
|
|
2125
2133
|
updateSubagentStopButton(id, isSubagentCancelInFlight(id), status);
|
|
2134
|
+
applySubagentFold(id);
|
|
2126
2135
|
return bubble;
|
|
2127
2136
|
}
|
|
2128
2137
|
|
|
@@ -2134,8 +2143,19 @@ function ensureSubagentBubble(subagentRunId, depth = 0) {
|
|
|
2134
2143
|
bubble.className = 'subagent-bubble';
|
|
2135
2144
|
bubble.dataset.subagentRunId = id;
|
|
2136
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.
|
|
2137
2148
|
const header = document.createElement('div');
|
|
2138
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);
|
|
2139
2159
|
|
|
2140
2160
|
const nameSpan = document.createElement('span');
|
|
2141
2161
|
nameSpan.className = 'subagent-bubble-name';
|
|
@@ -2149,6 +2169,10 @@ function ensureSubagentBubble(subagentRunId, depth = 0) {
|
|
|
2149
2169
|
? '● Running'
|
|
2150
2170
|
: normalizeSubagentBubbleStatus(status).charAt(0).toUpperCase() + normalizeSubagentBubbleStatus(status).slice(1);
|
|
2151
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
|
+
|
|
2152
2176
|
const controls = document.createElement('div');
|
|
2153
2177
|
controls.className = 'subagent-bubble-controls';
|
|
2154
2178
|
controls.appendChild(statusSpan);
|
|
@@ -2161,6 +2185,7 @@ function ensureSubagentBubble(subagentRunId, depth = 0) {
|
|
|
2161
2185
|
controls.appendChild(stopBtn);
|
|
2162
2186
|
|
|
2163
2187
|
header.appendChild(nameSpan);
|
|
2188
|
+
header.appendChild(summarySpan);
|
|
2164
2189
|
header.appendChild(controls);
|
|
2165
2190
|
|
|
2166
2191
|
const activityBox = document.createElement('div');
|
|
@@ -2174,6 +2199,7 @@ function ensureSubagentBubble(subagentRunId, depth = 0) {
|
|
|
2174
2199
|
bubble.appendChild(activityBox);
|
|
2175
2200
|
container.appendChild(bubble);
|
|
2176
2201
|
updateSubagentStopButton(id, isSubagentCancelInFlight(id), status);
|
|
2202
|
+
applySubagentFold(id);
|
|
2177
2203
|
|
|
2178
2204
|
const entry = getSubagentRun(id);
|
|
2179
2205
|
if (entry?.thoughts?.length) {
|
|
@@ -2203,10 +2229,22 @@ function ensureSubagentBubble(subagentRunId, depth = 0) {
|
|
|
2203
2229
|
if (entry?.streamText) {
|
|
2204
2230
|
renderSubagentStream(id, entry.streamText);
|
|
2205
2231
|
}
|
|
2232
|
+
refreshSubagentSummary(bubble);
|
|
2206
2233
|
|
|
2207
2234
|
return bubble;
|
|
2208
2235
|
}
|
|
2209
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
|
+
|
|
2210
2248
|
function updateSubagentBubbleStatus(bubble, status) {
|
|
2211
2249
|
if (!bubble) return;
|
|
2212
2250
|
const statusSpan = bubble.querySelector('.subagent-bubble-status');
|
|
@@ -2216,6 +2254,38 @@ function updateSubagentBubbleStatus(bubble, status) {
|
|
|
2216
2254
|
statusSpan.textContent = normalized === 'running' ? '● Running' : normalized.charAt(0).toUpperCase() + normalized.slice(1);
|
|
2217
2255
|
}
|
|
2218
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
|
+
|
|
2219
2289
|
const subagentStopUnsupported = new Set();
|
|
2220
2290
|
|
|
2221
2291
|
/** The provider answered "not supported": the control must not re-arm. */
|
|
@@ -2236,6 +2306,11 @@ function updateSubagentStopButton(subagentRunId, isStopping = false, statusOverr
|
|
|
2236
2306
|
btn.disabled = true;
|
|
2237
2307
|
return;
|
|
2238
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;
|
|
2239
2314
|
if (subagentStopUnsupported.has(id)) {
|
|
2240
2315
|
btn.disabled = true;
|
|
2241
2316
|
btn.textContent = 'Stop unavailable';
|
|
@@ -2243,8 +2318,6 @@ function updateSubagentStopButton(subagentRunId, isStopping = false, statusOverr
|
|
|
2243
2318
|
btn.classList.remove('stopping');
|
|
2244
2319
|
return;
|
|
2245
2320
|
}
|
|
2246
|
-
const status = normalizeSubagentBubbleStatus(statusOverride || getSubagentStatus(id));
|
|
2247
|
-
const terminal = isSubagentTerminalStatus(status);
|
|
2248
2321
|
const stopping = !!isStopping;
|
|
2249
2322
|
btn.disabled = terminal || stopping;
|
|
2250
2323
|
btn.textContent = stopping ? 'Stopping…' : 'Stop';
|
|
@@ -2263,6 +2336,7 @@ export function updateSubagentBubbleFromStatus(subagentRunId, status) {
|
|
|
2263
2336
|
clearSubagentCancelInFlight(id);
|
|
2264
2337
|
}
|
|
2265
2338
|
updateSubagentStopButton(id, isSubagentCancelInFlight(id), status);
|
|
2339
|
+
applySubagentFold(id);
|
|
2266
2340
|
}
|
|
2267
2341
|
|
|
2268
2342
|
function renderSubagentBubbleRecursive(entry) {
|
|
@@ -2990,7 +3064,7 @@ function runRelayErrorCta(cta) {
|
|
|
2990
3064
|
}
|
|
2991
3065
|
|
|
2992
3066
|
function handleBubbleActionClick(event) {
|
|
2993
|
-
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');
|
|
2994
3068
|
if (!btn) return;
|
|
2995
3069
|
const action = btn.dataset.action;
|
|
2996
3070
|
const messageId = btn.dataset.messageId;
|
|
@@ -3043,6 +3117,22 @@ function handleBubbleActionClick(event) {
|
|
|
3043
3117
|
event.stopPropagation();
|
|
3044
3118
|
void cancelSubagentByRunId(currentConvId, subagentRunId);
|
|
3045
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);
|
|
3046
3136
|
}
|
|
3047
3137
|
|
|
3048
3138
|
// Clicking an inline-embedded image in a message bubble opens it in the file
|
|
@@ -3075,6 +3165,7 @@ export function initBubbleActionHandlers() {
|
|
|
3075
3165
|
const messagesEl = document.getElementById('messages');
|
|
3076
3166
|
if (!messagesEl) return;
|
|
3077
3167
|
messagesEl.addEventListener('click', handleBubbleActionClick);
|
|
3168
|
+
messagesEl.addEventListener('keydown', handleBubbleActionKeydown);
|
|
3078
3169
|
messagesEl.addEventListener('click', handleEmbeddedImageClick);
|
|
3079
3170
|
initThinkingFollow({ getCurrentConversationId: () => currentConvId });
|
|
3080
3171
|
}
|
|
@@ -3112,6 +3203,8 @@ export function appendMessage(msg, scroll = true, msgId = null, force = false, i
|
|
|
3112
3203
|
}
|
|
3113
3204
|
}
|
|
3114
3205
|
if (isNewNode && insertedNode) {
|
|
3206
|
+
// A reply takes over the board its turn posted (relay-board-view).
|
|
3207
|
+
if (msg?.role === 'assistant') renderRelayBoards();
|
|
3115
3208
|
syncSeparatorsNow();
|
|
3116
3209
|
if (scroll) scrollBottom();
|
|
3117
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;
|