@oar-sh/oar 0.9.5 → 0.9.7
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 +340 -0
- package/README.md +196 -26
- package/package.json +2 -1
- package/server/README.md +174 -7
- package/server/claude-cloud-worker/claude-cloud-event-normalizer.mjs +340 -0
- package/server/claude-cloud-worker/claude-cloud-session-process.mjs +1325 -0
- package/server/claude-cloud-worker/claude-cloud-session-worker.mjs +155 -0
- package/server/claude-worker/claude-remote-relay-tool.mjs +2 -0
- package/server/claude-worker/claude-sdk-adapter.mjs +18 -2
- package/server/claude-worker/claude-session-process.mjs +151 -1
- package/server/claude-worker/claude-session-worker.mjs +6 -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/cursor-worker/cursor-sdk-adapter.mjs +80 -13
- package/server/cursor-worker/cursor-turn-runner.mjs +5 -2
- package/server/db-schema.mjs +24 -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/migrations/0006-workspace-root-settings.mjs +67 -0
- package/server/migrations/0007-claude-cloud.mjs +82 -0
- package/server/public/app/agent-sessions-settings-ui.js +197 -0
- package/server/public/app/agent-sessions-settings.mjs +134 -0
- package/server/public/app/api-client.js +160 -6
- package/server/public/app/attachments-view.js +20 -1
- package/server/public/app/attribution-control.mjs +46 -0
- package/server/public/app/bootstrap.js +347 -31
- package/server/public/app/claude-cloud-conversation-ui.js +110 -0
- package/server/public/app/claude-cloud-settings-ui.js +374 -0
- package/server/public/app/claude-cloud-ui.mjs +611 -0
- package/server/public/app/composer-control-state.mjs +15 -3
- package/server/public/app/context-menu.mjs +140 -0
- package/server/public/app/context-tier-options.mjs +4 -0
- package/server/public/app/conversation-origin-ui.js +27 -0
- package/server/public/app/conversation-provider-indicator.mjs +22 -4
- package/server/public/app/conversation-view.js +178 -8
- package/server/public/app/cwd-picker.js +12 -0
- package/server/public/app/journal-view.js +704 -28
- package/server/public/app/markdown-strikethrough.mjs +31 -0
- package/server/public/app/math-dollars.mjs +89 -0
- package/server/public/app/model-selector-options.mjs +1 -1
- package/server/public/app/plan-usage-view.mjs +36 -8
- 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 +28 -5
- package/server/public/app/remote-relay-origin-view.mjs +120 -4
- package/server/public/app/router.js +49 -12
- package/server/public/app/settings-modal.js +33 -2
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/socket-handlers.js +60 -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 +471 -8
- package/server/relay-tools.md +13 -1
- package/server/repositories/message-repository.mjs +10 -1
- package/server/repositories/question-repository.mjs +1 -0
- package/server/repositories/remote-relay-repository.mjs +72 -1
- package/server/repositories/session-repository.mjs +47 -1
- package/server/routes/claude-cloud-routes.mjs +140 -0
- package/server/routes/git-routes.mjs +37 -0
- package/server/routes/messages-routes.mjs +202 -26
- package/server/routes/relay-board-routes.mjs +56 -2
- package/server/routes/remote-relay-routes.mjs +15 -1
- package/server/routes/sessions-routes.mjs +538 -72
- package/server/server-runtime.mjs +291 -27
- package/server/services/claude-account-usage-service.mjs +176 -0
- package/server/services/claude-cloud-repo-service.mjs +408 -0
- package/server/services/claude-cloud-session-service.mjs +326 -0
- package/server/services/claude-cloud-settings-service.mjs +269 -0
- package/server/services/git-remote-service.mjs +115 -0
- package/server/services/plan-usage-claude-cloud.mjs +211 -0
- package/server/services/plan-usage-claude.mjs +202 -40
- package/server/services/plan-usage-contract.mjs +39 -3
- package/server/services/plan-usage-service.mjs +82 -2
- package/server/services/preview-proxy-server.mjs +64 -36
- package/server/services/process-list-service.mjs +122 -0
- package/server/services/provider-reasoning-effort.mjs +3 -0
- package/server/services/relay-console-log.mjs +199 -0
- package/server/services/remote-relay-caller-context.mjs +78 -19
- package/server/services/remote-relay-client.mjs +9 -0
- package/server/services/remote-relay-dispatcher.mjs +451 -39
- package/server/services/remote-relay-inbound.mjs +83 -1
- package/server/services/remote-relay-loopback.mjs +245 -0
- package/server/services/remote-relay-registry-service.mjs +52 -0
- package/server/services/session-worker-launch-service.mjs +100 -15
- package/server/services/session-worker-process-service.mjs +410 -138
- package/server/services/session-worker-registry-service.mjs +1 -1
- package/server/services/session-worker-stop-service.mjs +24 -13
- package/server/services/session-worker-supervisor-service.mjs +89 -2
- package/server/services/usage-limit-pause-service.mjs +443 -0
- package/server/services/worker-launch-note-service.mjs +151 -0
- package/shared/ask-user-bridge.mjs +14 -39
- package/shared/claude-attribution.mjs +117 -0
- package/shared/claude-cloud/api-client.mjs +809 -0
- package/shared/claude-cloud/base-url.mjs +47 -0
- package/shared/claude-cloud/credentials.mjs +221 -0
- package/shared/claude-cloud/repo-url.mjs +111 -0
- 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/provider-routing.mjs +4 -2
- package/shared/question-wait.mjs +108 -0
- package/shared/remote-relay-contract.mjs +167 -22
- package/shared/remote-relay-tool-core.mjs +11 -8
- package/shared/steer-settle-failure.mjs +17 -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
|
@@ -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,89 @@
|
|
|
1
|
+
// Which `$` in a run of text delimit inline maths, and which are plain dollar
|
|
2
|
+
// signs. Prices are the common case of the second kind: without a rule,
|
|
3
|
+
// "$5 for one and $9 for two" reads as one formula running from the first
|
|
4
|
+
// sign to the second.
|
|
5
|
+
//
|
|
6
|
+
// The rule is the one pandoc uses for dollar maths: the opening `$` has a
|
|
7
|
+
// non-space character directly after it, the closing `$` is the next dollar
|
|
8
|
+
// sign, has a non-space character directly before it and no digit directly
|
|
9
|
+
// after it. A `$$` pair is
|
|
10
|
+
// display maths and is left alone, and a `$` behind a backslash is escaped.
|
|
11
|
+
|
|
12
|
+
const TEX_LOOKING = /[\\^_{}=]/;
|
|
13
|
+
|
|
14
|
+
function isEscaped(text, index) {
|
|
15
|
+
let backslashes = 0;
|
|
16
|
+
for (let i = index - 1; i >= 0 && text[i] === '\\'; i -= 1) backslashes += 1;
|
|
17
|
+
return backslashes % 2 === 1;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function isSpace(char) {
|
|
21
|
+
return char === undefined || /\s/.test(char);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// A formula holds no dollar sign of its own, so only the next one can close
|
|
25
|
+
// it. When that one does not qualify ("$5, and $x^2$": it follows a space),
|
|
26
|
+
// the opening sign is a plain dollar and the next one gets its own turn.
|
|
27
|
+
function findClosingDollar(text, openIndex) {
|
|
28
|
+
let i = openIndex + 1;
|
|
29
|
+
while (i < text.length && (text[i] !== '$' || isEscaped(text, i))) i += 1;
|
|
30
|
+
if (i >= text.length) return -1;
|
|
31
|
+
// Part of a `$$`: not the end of an inline formula.
|
|
32
|
+
if (text[i + 1] === '$') return -1;
|
|
33
|
+
if (isSpace(text[i - 1])) return -1;
|
|
34
|
+
const content = text.slice(openIndex + 1, i);
|
|
35
|
+
// "$5/$10": a digit right behind the sign means the sign starts a number.
|
|
36
|
+
// Real maths directly followed by a digit ("$x^2$3") is told apart by
|
|
37
|
+
// looking like TeX.
|
|
38
|
+
if (/\d/.test(text[i + 1] || '') && !TEX_LOOKING.test(content)) return -1;
|
|
39
|
+
return i;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Scan `text` for single-dollar maths.
|
|
44
|
+
* @returns {{ spans: Array<[number, number]>, literals: number[] }} `spans`
|
|
45
|
+
* are [open, close] index pairs of formulas, `literals` the indexes of `$`
|
|
46
|
+
* that are plain dollar signs.
|
|
47
|
+
*/
|
|
48
|
+
export function scanInlineDollars(text) {
|
|
49
|
+
const source = String(text ?? '');
|
|
50
|
+
const spans = [];
|
|
51
|
+
const literals = [];
|
|
52
|
+
let index = 0;
|
|
53
|
+
while (index < source.length) {
|
|
54
|
+
if (source[index] !== '$' || isEscaped(source, index)) {
|
|
55
|
+
index += 1;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (source[index + 1] === '$') {
|
|
59
|
+
const close = source.indexOf('$$', index + 2);
|
|
60
|
+
index = close < 0 ? index + 2 : close + 2;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
const close = isSpace(source[index + 1]) ? -1 : findClosingDollar(source, index);
|
|
64
|
+
if (close < 0) {
|
|
65
|
+
literals.push(index);
|
|
66
|
+
index += 1;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
spans.push([index, close]);
|
|
70
|
+
index = close + 1;
|
|
71
|
+
}
|
|
72
|
+
return { spans, literals };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** `text` cut at its plain dollar signs: `[{ text, literal }]`, in order. */
|
|
76
|
+
export function splitAtLiteralDollars(text) {
|
|
77
|
+
const source = String(text ?? '');
|
|
78
|
+
const { literals } = scanInlineDollars(source);
|
|
79
|
+
if (!literals.length) return [{ text: source, literal: false }];
|
|
80
|
+
const parts = [];
|
|
81
|
+
let from = 0;
|
|
82
|
+
for (const index of literals) {
|
|
83
|
+
if (index > from) parts.push({ text: source.slice(from, index), literal: false });
|
|
84
|
+
parts.push({ text: '$', literal: true });
|
|
85
|
+
from = index + 1;
|
|
86
|
+
}
|
|
87
|
+
if (from < source.length) parts.push({ text: source.slice(from), literal: false });
|
|
88
|
+
return parts;
|
|
89
|
+
}
|
|
@@ -12,7 +12,7 @@ export function composerPlaceholderFor({ modelId = '', providerType = '' } = {})
|
|
|
12
12
|
const provider = String(providerType || '').trim().toLowerCase();
|
|
13
13
|
const id = String(modelId || '').trim().toLowerCase().replace(/\[[^\]]*\]$/, '');
|
|
14
14
|
const providerFallback = () => {
|
|
15
|
-
if (provider === 'claude') return 'Message Claude…';
|
|
15
|
+
if (provider === 'claude' || provider === 'claude-cloud') return 'Message Claude…';
|
|
16
16
|
if (provider === 'grok') return 'Message Grok…';
|
|
17
17
|
if (provider === 'openai' || provider === 'openai-byok') return 'Message OpenAI…';
|
|
18
18
|
if (provider === 'cursor') return 'Message Cursor…';
|
|
@@ -32,6 +32,7 @@ const SOURCE_LABELS = Object.freeze(Object.assign(Object.create(null), {
|
|
|
32
32
|
const TAB_LABELS = Object.freeze(Object.assign(Object.create(null), {
|
|
33
33
|
github: 'Copilot',
|
|
34
34
|
claude: 'Claude',
|
|
35
|
+
'claude-cloud': 'Claude Cloud',
|
|
35
36
|
cursor: 'Cursor',
|
|
36
37
|
grok: 'Grok',
|
|
37
38
|
}));
|
|
@@ -96,26 +97,32 @@ export function formatAmount(value, unit) {
|
|
|
96
97
|
return String(Math.round(n * 100) / 100);
|
|
97
98
|
}
|
|
98
99
|
|
|
99
|
-
|
|
100
|
+
/**
|
|
101
|
+
* @param {object} [options]
|
|
102
|
+
* @param {boolean} [options.expiry] the date is when the allowance ends for
|
|
103
|
+
* good (a credit), so it reads "expires …" instead of "resets …"
|
|
104
|
+
*/
|
|
105
|
+
export function formatResetCountdown(resetAt, now = new Date(), { expiry = false } = {}) {
|
|
100
106
|
if (!resetAt) return null;
|
|
101
107
|
const target = new Date(resetAt);
|
|
102
108
|
if (Number.isNaN(target.getTime())) return null;
|
|
103
109
|
const reference = now instanceof Date && !Number.isNaN(now.getTime()) ? now : new Date();
|
|
110
|
+
const verb = expiry ? 'expires' : 'resets';
|
|
104
111
|
const diffMs = target.getTime() - reference.getTime();
|
|
105
|
-
if (diffMs <= 0) return 'resets now';
|
|
112
|
+
if (diffMs <= 0) return expiry ? 'expired' : 'resets now';
|
|
106
113
|
const minutes = Math.floor(diffMs / 60000);
|
|
107
|
-
if (minutes < 60) return
|
|
114
|
+
if (minutes < 60) return `${verb} in ${Math.max(1, minutes)} min`;
|
|
108
115
|
const hours = Math.floor(minutes / 60);
|
|
109
116
|
if (hours < 24) {
|
|
110
117
|
const remainderMinutes = minutes % 60;
|
|
111
|
-
return remainderMinutes ?
|
|
118
|
+
return remainderMinutes ? `${verb} in ${hours} h ${remainderMinutes} min` : `${verb} in ${hours} h`;
|
|
112
119
|
}
|
|
113
120
|
const days = Math.floor(hours / 24);
|
|
114
121
|
if (days <= 14) {
|
|
115
122
|
const remainderHours = hours % 24;
|
|
116
|
-
return remainderHours ?
|
|
123
|
+
return remainderHours ? `${verb} in ${days} d ${remainderHours} h` : `${verb} in ${days} d`;
|
|
117
124
|
}
|
|
118
|
-
return
|
|
125
|
+
return `${verb} ${target.toISOString().slice(0, 10)}`;
|
|
119
126
|
}
|
|
120
127
|
|
|
121
128
|
/**
|
|
@@ -153,12 +160,17 @@ function renderMeter(meter, now) {
|
|
|
153
160
|
const width = meter.unlimited ? 100 : Math.max(0, Math.min(100, utilization ?? 0));
|
|
154
161
|
const color = meter.unlimited ? '#3fb950' : utilizationColor(utilization);
|
|
155
162
|
const summary = meterSummaryText(meter);
|
|
156
|
-
const countdown = formatResetCountdown(meter.resetAt, now);
|
|
163
|
+
const countdown = formatResetCountdown(meter.resetAt, now, { expiry: meter.resetKind === 'expiry' });
|
|
157
164
|
const percentLabel = meter.unlimited
|
|
158
165
|
? '∞'
|
|
159
166
|
: (utilization === null ? '—' : `${Math.round(utilization)}%`);
|
|
167
|
+
// The provider's own severity word; "normal" is the absence of a warning.
|
|
168
|
+
const severity = String(meter.severity ?? '').trim().toLowerCase();
|
|
160
169
|
const flags = [
|
|
161
170
|
meter.estimated ? '<span class="plan-usage-flag">estimated</span>' : '',
|
|
171
|
+
severity && severity !== 'normal'
|
|
172
|
+
? `<span class="plan-usage-flag plan-usage-flag-severity" data-severity="${escapeHtml(severity)}">${escapeHtml(severity.replace(/[_-]+/g, ' '))}</span>`
|
|
173
|
+
: '',
|
|
162
174
|
].filter(Boolean).join('');
|
|
163
175
|
const meta = [summary, countdown].filter(Boolean).join(' · ');
|
|
164
176
|
|
|
@@ -193,6 +205,19 @@ function renderDetailSection(section) {
|
|
|
193
205
|
`;
|
|
194
206
|
}
|
|
195
207
|
|
|
208
|
+
/** A line of text on the card itself, with an optional link behind it. */
|
|
209
|
+
function renderNote(note) {
|
|
210
|
+
const text = String(note?.text ?? '').trim();
|
|
211
|
+
if (!text) return '';
|
|
212
|
+
const url = String(note?.link?.url ?? '').trim();
|
|
213
|
+
const label = String(note?.link?.label ?? '').trim();
|
|
214
|
+
// Same rule as the server's: only an absolute https link becomes an href.
|
|
215
|
+
const link = label && /^https:\/\//i.test(url)
|
|
216
|
+
? ` <a class="plan-usage-link" href="${escapeHtml(url)}" target="_blank" rel="noopener noreferrer">${escapeHtml(label)}</a>`
|
|
217
|
+
: '';
|
|
218
|
+
return `<div class="plan-usage-detail-note plan-usage-note" data-note-id="${escapeHtml(note?.id ?? '')}">${escapeHtml(text)}${link}</div>`;
|
|
219
|
+
}
|
|
220
|
+
|
|
196
221
|
/**
|
|
197
222
|
* The signed-in account under the card title. Three distinct states, because
|
|
198
223
|
* "we could not ask" must not be drawn as "you are logged out": no `account`
|
|
@@ -218,6 +243,7 @@ function renderAccountLine(account) {
|
|
|
218
243
|
function renderCard(card, now, { active = true } = {}) {
|
|
219
244
|
const meters = Array.isArray(card.meters) ? card.meters : [];
|
|
220
245
|
const details = Array.isArray(card.details) ? card.details : [];
|
|
246
|
+
const notes = Array.isArray(card.notes) ? card.notes.filter(Boolean) : [];
|
|
221
247
|
const links = Array.isArray(card.links) ? card.links : [];
|
|
222
248
|
|
|
223
249
|
const statusBadge = STATUS_BADGES[card.status];
|
|
@@ -255,6 +281,7 @@ function renderCard(card, now, { active = true } = {}) {
|
|
|
255
281
|
</header>
|
|
256
282
|
${card.message ? `<div class="plan-usage-message">${escapeHtml(card.message)}</div>` : ''}
|
|
257
283
|
${metersHtml}
|
|
284
|
+
${notes.map(renderNote).join('')}
|
|
258
285
|
${details.map(renderDetailSection).join('')}
|
|
259
286
|
<div class="plan-usage-card-foot">
|
|
260
287
|
${capturedLabel ? `<span class="plan-usage-captured">${capturedLabel}</span>` : ''}
|
|
@@ -296,8 +323,9 @@ export function planUsageSubtitle(report) {
|
|
|
296
323
|
if (!providers.length) return 'No usage data available';
|
|
297
324
|
const available = providers.filter((card) => Array.isArray(card.meters) && card.meters.length);
|
|
298
325
|
if (!available.length) return 'No plan limits reported yet';
|
|
326
|
+
// A credit's expiry is not a reset: nothing refills on that date.
|
|
299
327
|
const soonest = available
|
|
300
|
-
.flatMap((card) => card.meters.map((meter) => meter
|
|
328
|
+
.flatMap((card) => card.meters.map((meter) => (meter?.resetKind === 'expiry' ? null : meter?.resetAt)))
|
|
301
329
|
.filter(Boolean)
|
|
302
330
|
.sort()[0];
|
|
303
331
|
const countdown = formatResetCountdown(soonest);
|
|
@@ -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,16 +28,35 @@ 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;
|
|
42
|
+
// The shape of a failure note, mirrored from shared/failure-note-text.mjs
|
|
43
|
+
// (the browser cannot import it; failure-note-text.test.mjs keeps the two in
|
|
44
|
+
// step): one paragraph of at most this length leading up to the code, not a
|
|
45
|
+
// quotation, a code span, a table cell, a list item or a heading. A reply
|
|
46
|
+
// that talks about a code gets no button.
|
|
47
|
+
const FAILURE_NOTE_LEAD_MAX = 600;
|
|
48
|
+
const QUOTED_LEAD = /["“”'`|>]\s*$/;
|
|
49
|
+
const MARKUP_LEAD = /^\s*(?:#+|[>|*-]|\d+\.)\s/;
|
|
33
50
|
|
|
51
|
+
/** The code of a text that is a failure note of the relay, else ''. */
|
|
34
52
|
export function relayErrorCodeFromText(text) {
|
|
35
|
-
const
|
|
36
|
-
|
|
53
|
+
const raw = String(text || '').trim();
|
|
54
|
+
const match = raw.match(STABLE_CODE_PATTERN);
|
|
55
|
+
if (!match) return '';
|
|
56
|
+
const lead = raw.slice(0, match.index);
|
|
57
|
+
if (lead.length > FAILURE_NOTE_LEAD_MAX || /[\r\n]/.test(lead)) return '';
|
|
58
|
+
if (QUOTED_LEAD.test(lead) || MARKUP_LEAD.test(lead)) return '';
|
|
59
|
+
return match[1].toLowerCase();
|
|
37
60
|
}
|
|
38
61
|
|
|
39
62
|
/**
|