@oar-sh/oar 0.9.12 → 0.9.14
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/extension.mjs +9 -0
- package/.github/extensions/web-relay/server-lifecycle/managed-server.mjs +15 -1
- package/.github/extensions/web-relay/skills/question-bridge.mjs +4 -1
- package/CHANGELOG.md +508 -0
- package/README.md +12 -12
- package/bin/oar.js +155 -94
- package/package.json +5 -5
- package/server/README.md +479 -157
- package/server/claude-cloud-worker/claude-cloud-session-process.mjs +10 -4
- package/server/claude-worker/claude-session-process.mjs +92 -15
- package/server/claude-worker/claude-turn-publisher.mjs +20 -1
- package/server/claude-worker/subagent-timeline-recorder.mjs +75 -8
- package/server/claude-worker/task-output-discovery.mjs +28 -2
- package/server/copilot-worker/copilot-ask-user-tool.mjs +1 -1
- package/server/copilot-worker/copilot-continuation-signals.mjs +11 -5
- package/server/copilot-worker/copilot-prompt-context.mjs +3 -1
- package/server/copilot-worker/copilot-question-bridge.mjs +11 -6
- package/server/copilot-worker/copilot-sdk-adapter.mjs +11 -0
- package/server/copilot-worker/copilot-sdk-session-process.mjs +24 -1
- package/server/cursor-worker/cursor-ask-user-tool.mjs +1 -1
- package/server/cursor-worker/cursor-sdk-adapter.mjs +22 -7
- package/server/cursor-worker/cursor-session-worker.mjs +9 -2
- package/server/cursor-worker/cursor-turn-runner.mjs +18 -3
- package/server/db-schema.mjs +10 -0
- package/server/grok-worker/acp-host-services.mjs +6 -3
- package/server/grok-worker/grok-background-tasks.mjs +7 -0
- package/server/grok-worker/grok-sdk-adapter.mjs +14 -0
- package/server/grok-worker/grok-turn-runner.mjs +13 -3
- package/server/migrations/0018-background-task-rows.mjs +101 -0
- package/server/public/app/action-confirmations.js +12 -15
- package/server/public/app/api-client.js +60 -4
- package/server/public/app/ask-user-view.js +29 -4
- package/server/public/app/background-tasks-view.mjs +56 -7
- package/server/public/app/bootstrap.js +7 -0
- package/server/public/app/conversation-preferences.mjs +10 -4
- package/server/public/app/conversation-provider-indicator.mjs +18 -8
- package/server/public/app/conversation-view.js +33 -9
- package/server/public/app/host-suspend-ui.js +45 -44
- package/server/public/app/journal-view.js +9 -3
- package/server/public/app/push-settings.js +1 -0
- package/server/public/app/question-answer-rule.mjs +15 -0
- package/server/public/app/question-multi-select.mjs +9 -2
- package/server/public/app/relay-event-view.mjs +5 -1
- package/server/public/app/relay-prompt-sanitizer.mjs +9 -2
- package/server/public/app/relay-restart-note-view.mjs +36 -0
- package/server/public/app/relay-restart-ui.js +568 -0
- package/server/public/app/settings-modal.js +52 -0
- package/server/public/app/socket-handlers.js +1 -1
- package/server/public/app/task-console-render.mjs +171 -7
- package/server/public/app/task-console.mjs +111 -11
- package/server/public/app/task-output-text.mjs +15 -6
- package/server/public/app/transcript-jump-bottom-view.mjs +5 -1
- package/server/public/app/transcript-jump-bottom.mjs +20 -0
- package/server/public/index.html +76 -5
- package/server/relay-exit-codes.mjs +6 -0
- package/server/relay-self-restart.mjs +6 -1
- package/server/relay-tools.md +17 -9
- package/server/repositories/message-repository.mjs +32 -28
- package/server/repositories/remote-relay-repository.mjs +4 -2
- package/server/repositories/subagent-timeline-statements.mjs +2 -0
- package/server/routes/ask-user-routes.mjs +41 -7
- package/server/routes/messages-routes.mjs +220 -37
- package/server/routes/preview-routes.mjs +73 -7
- package/server/routes/relay-board-routes.mjs +19 -2
- package/server/routes/relay-restart-routes.mjs +73 -0
- package/server/routes/sessions-routes.mjs +79 -5
- package/server/server-runtime.mjs +335 -218
- package/server/services/about-service.mjs +13 -12
- package/server/services/background-task-persistence.mjs +133 -0
- package/server/services/background-task-store.mjs +240 -13
- package/server/services/conversation-model-fallback.mjs +57 -0
- package/server/services/cursor-session-root-service.mjs +70 -6
- package/server/services/data-dir-resolution.mjs +208 -19
- package/server/services/earlier-database-service.mjs +631 -0
- package/server/services/host-suspend-command.mjs +2 -1
- package/server/services/oar-cli-helpers.mjs +6 -1
- package/server/services/oar-state-layout.mjs +107 -0
- package/server/services/oar-state-migration-service.mjs +387 -96
- package/server/services/package-data-rescue-preinstall.mjs +22 -0
- package/server/services/package-data-rescue.mjs +137 -0
- package/server/services/preview-proxy-server.mjs +14 -1
- package/server/services/preview-registry-service.mjs +3 -2
- package/server/services/process-list-service.mjs +2 -1
- package/server/services/provider-model-selection.mjs +195 -0
- package/server/services/push-dispatch-service.mjs +42 -8
- package/server/services/relay-event-service.mjs +210 -14
- package/server/services/relay-prompt-sanitizer.mjs +7 -0
- package/server/services/relay-restart-picture.mjs +209 -0
- package/server/services/relay-restart-queue.mjs +387 -0
- package/server/services/relay-shutdown-gate.mjs +4 -1
- package/server/services/relay-singleton-guard.mjs +300 -36
- package/server/services/remote-relay-caller-context.mjs +7 -2
- package/server/services/remote-relay-dispatcher.mjs +217 -28
- package/server/services/restart-resume-service.mjs +303 -0
- package/server/services/session-activity-tracker.mjs +52 -15
- package/server/services/session-worker-launch-service.mjs +31 -1
- package/server/services/session-worker-process-service.mjs +6 -4
- package/server/services/stale-turn-recovery.mjs +99 -0
- package/server/services/subagent-timeline-service.mjs +4 -1
- package/server/services/task-output-service.mjs +217 -18
- package/server/services/windows-autostart-service.mjs +329 -31
- package/server/services/windows-boot-autostart-service.mjs +188 -60
- package/shared/preview-instructions.mjs +17 -12
- package/shared/preview-tool-core.mjs +32 -14
- package/shared/question-answer-rule.mjs +63 -0
- package/shared/relay-events.mjs +106 -3
- package/shared/remote-relay-contract.mjs +32 -15
- package/shared/subagent-timeline.mjs +4 -0
- package/shared/task-output-text.mjs +196 -83
- package/shared/windows-system-tools.mjs +25 -0
- package/shared/worker-runtime/config-loader.mjs +54 -8
- package/shared/worker-runtime/prompt-context.mjs +3 -1
- package/shared/worker-runtime/worker-websocket-link.mjs +96 -20
- package/shared/worker-runtime/working-directory.mjs +65 -0
- package/server/uploads/image_input.md +0 -540
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
} from './api-client.js';
|
|
21
21
|
import { isSuspendHostActionVisible } from './settings-modal.js';
|
|
22
22
|
import { describeRelayRestartError } from './relay-restart-error.mjs';
|
|
23
|
+
import { initRelayRestartUi, openRestartRelayConfirmation } from './relay-restart-ui.js';
|
|
23
24
|
import {
|
|
24
25
|
applyHostSuspendState,
|
|
25
26
|
describeHostSuspendBlocker,
|
|
@@ -106,21 +107,10 @@ export async function confirmKillCurrentSession() {
|
|
|
106
107
|
}
|
|
107
108
|
}
|
|
108
109
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
kind: 'restart-relay',
|
|
114
|
-
bodyHtml: `
|
|
115
|
-
<p>Queue a manual relay restart now?</p>
|
|
116
|
-
<p>The restart waits until the current turn is idle, so it does not interrupt an in-flight turn immediately.</p>
|
|
117
|
-
<div class="summary-modal-actions">
|
|
118
|
-
<button class="chat-title-action-btn" type="button" onclick="confirmRestartWebRelay()">🌄 Restart web relay</button>
|
|
119
|
-
<button class="chat-title-action-btn" type="button" onclick="closeSummaryModal()">Cancel</button>
|
|
120
|
-
</div>
|
|
121
|
-
`,
|
|
122
|
-
});
|
|
123
|
-
}
|
|
110
|
+
// ⋯ → Restart web relay first shows what the restart would wait for and
|
|
111
|
+
// leave running (relay-restart-ui.js); its buttons land in
|
|
112
|
+
// confirmRestartWebRelay below or in confirmRestartRelayNow.
|
|
113
|
+
export { openRestartRelayConfirmation };
|
|
124
114
|
|
|
125
115
|
export function openEmptyQueueConfirmation() {
|
|
126
116
|
menuDeps.lockChatActionsMenuShield(350);
|
|
@@ -375,6 +365,10 @@ export async function confirmRestartWebRelay() {
|
|
|
375
365
|
showTransientRelayNotice('Relay is already shutting down/restarting.', 7000);
|
|
376
366
|
return;
|
|
377
367
|
}
|
|
368
|
+
if (result.status === 'shutting_down') {
|
|
369
|
+
showTransientRelayNotice('Nothing was running: the relay is restarting now.', 7000);
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
378
372
|
const queue = result.queue || {};
|
|
379
373
|
showTransientRelayNotice(
|
|
380
374
|
`Relay restart queued (pending=${Number(queue.pendingCount || 0)}, processing=${Number(queue.processingCount || 0)}).`,
|
|
@@ -399,6 +393,9 @@ export function initActionConfirmations({
|
|
|
399
393
|
syncQueueStatusMenuEntry: typeof syncQueueStatusMenuEntry === 'function' ? syncQueueStatusMenuEntry : menuDeps.syncQueueStatusMenuEntry,
|
|
400
394
|
refreshSessionWorkerStatus: typeof refreshSessionWorkerStatus === 'function' ? refreshSessionWorkerStatus : menuDeps.refreshSessionWorkerStatus,
|
|
401
395
|
};
|
|
396
|
+
// The restart confirmations' and banner's inline handlers live on window
|
|
397
|
+
// either way (bootstrap exposes this module's own).
|
|
398
|
+
initRelayRestartUi();
|
|
402
399
|
if (!exposeOnWindow) return;
|
|
403
400
|
window.openKillSessionConfirmation = openKillSessionConfirmation;
|
|
404
401
|
window.openRestartRelayConfirmation = openRestartRelayConfirmation;
|
|
@@ -45,7 +45,7 @@ export function areNetworkRequestsEnabled() {
|
|
|
45
45
|
return networkRequestsEnabled;
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
export async function apiFetch(url, opts = {}) {
|
|
48
|
+
export async function apiFetch(url, { onErrorResponse, ...opts } = {}) {
|
|
49
49
|
// A keepalive request is a page's last word; it goes out even when the
|
|
50
50
|
// page's regular network work is already suspended.
|
|
51
51
|
if (!networkRequestsEnabled && !opts.keepalive) return null;
|
|
@@ -61,6 +61,9 @@ export async function apiFetch(url, opts = {}) {
|
|
|
61
61
|
});
|
|
62
62
|
if (!response.ok) {
|
|
63
63
|
console.error('API error', response.status, url);
|
|
64
|
+
// A caller that shows why a request failed reads the answer here; the
|
|
65
|
+
// result is still null.
|
|
66
|
+
if (typeof onErrorResponse === 'function') await onErrorResponse(response);
|
|
64
67
|
return null;
|
|
65
68
|
}
|
|
66
69
|
noteFetchSuccess();
|
|
@@ -585,6 +588,18 @@ export async function updateTaskStallMinutesSetting(minutes) {
|
|
|
585
588
|
});
|
|
586
589
|
}
|
|
587
590
|
|
|
591
|
+
// "After a relay restart, tell interrupted sessions to carry on" (off by default).
|
|
592
|
+
export async function loadRestartResumeSetting() {
|
|
593
|
+
return apiFetch('/api/settings/restart-resume');
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
export async function updateRestartResumeSetting(enabled) {
|
|
597
|
+
return apiFetch('/api/settings/restart-resume', {
|
|
598
|
+
method: 'POST',
|
|
599
|
+
body: JSON.stringify({ enabled: enabled === true }),
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
|
|
588
603
|
export async function loadPwaAppNameSetting() {
|
|
589
604
|
return apiFetch('/api/settings/pwa-app-name');
|
|
590
605
|
}
|
|
@@ -1425,6 +1440,31 @@ export async function cancelRelayRestart(body = {}) {
|
|
|
1425
1440
|
});
|
|
1426
1441
|
}
|
|
1427
1442
|
|
|
1443
|
+
// The queued restart's state, and the picture for a restart asked now: the
|
|
1444
|
+
// sessions it would wait for, the messages it would hold, the background
|
|
1445
|
+
// tasks and whether they survive (routes/relay-restart-routes.mjs).
|
|
1446
|
+
export async function loadRelayRestartPreview(action = 'restart') {
|
|
1447
|
+
return apiFetchOrThrow(`/api/relay/shutdown?action=${encodeURIComponent(action)}`);
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
export async function keepWaitingForRelayRestart(body = {}) {
|
|
1451
|
+
return apiFetchOrThrow('/api/relay/shutdown/keep-waiting', {
|
|
1452
|
+
method: 'POST',
|
|
1453
|
+
body: JSON.stringify({ requestedBy: 'localhost-api', ...(body || {}) }),
|
|
1454
|
+
});
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
export async function loadRelayRestartMaxWaitSetting() {
|
|
1458
|
+
return apiFetch('/api/settings/relay-restart-max-wait');
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
export async function updateRelayRestartMaxWaitSetting(maxWaitMinutes) {
|
|
1462
|
+
return apiFetchOrThrow('/api/settings/relay-restart-max-wait', {
|
|
1463
|
+
method: 'POST',
|
|
1464
|
+
body: JSON.stringify({ maxWaitMinutes: Number(maxWaitMinutes) }),
|
|
1465
|
+
});
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1428
1468
|
export async function requestQueueEmpty(body = {}) {
|
|
1429
1469
|
return apiFetchOrThrow('/api/queue/empty', {
|
|
1430
1470
|
method: 'POST',
|
|
@@ -1436,8 +1476,22 @@ export async function requestQueueEmpty(body = {}) {
|
|
|
1436
1476
|
});
|
|
1437
1477
|
}
|
|
1438
1478
|
|
|
1439
|
-
|
|
1440
|
-
|
|
1479
|
+
// Null on any failure, like apiFetch. A send the relay refused for a reason
|
|
1480
|
+
// the user can act on (a 409 such as "this conversation's model is no longer
|
|
1481
|
+
// available") hands that reason to onRefused first, so the composer can say
|
|
1482
|
+
// it instead of "could not be sent". A replayed send that already landed
|
|
1483
|
+
// (DUPLICATE_MESSAGE_ID) is not a refusal.
|
|
1484
|
+
export async function sendMessage(body, { onRefused } = {}) {
|
|
1485
|
+
return apiFetch('/api/message', {
|
|
1486
|
+
method: 'POST',
|
|
1487
|
+
body: JSON.stringify(body),
|
|
1488
|
+
onErrorResponse: async (response) => {
|
|
1489
|
+
if (response.status !== 409 || typeof onRefused !== 'function') return;
|
|
1490
|
+
const payload = await response.json().catch(() => null);
|
|
1491
|
+
const reason = readableServerError(payload?.error);
|
|
1492
|
+
if (reason && payload?.code !== 'DUPLICATE_MESSAGE_ID') onRefused(reason, payload);
|
|
1493
|
+
},
|
|
1494
|
+
});
|
|
1441
1495
|
}
|
|
1442
1496
|
|
|
1443
1497
|
// Raw fetch instead of apiFetch: a rejected bootstrap (bad CWD, unavailable
|
|
@@ -1506,13 +1560,15 @@ export async function loadRelayQuestion(questionId) {
|
|
|
1506
1560
|
return apiFetch(`/api/relay-question/${encodeURIComponent(id)}`);
|
|
1507
1561
|
}
|
|
1508
1562
|
|
|
1509
|
-
|
|
1563
|
+
// `onErrorResponse` reads the relay's refusal (why, and a card's choices).
|
|
1564
|
+
export async function answerRelayQuestion(questionId, answer, sdkSessionId = null, { onErrorResponse } = {}) {
|
|
1510
1565
|
const id = String(questionId || '').trim();
|
|
1511
1566
|
if (!id) return null;
|
|
1512
1567
|
const sessionId = String(sdkSessionId || '').trim();
|
|
1513
1568
|
return apiFetch(`/api/relay-question/${encodeURIComponent(id)}/answer`, {
|
|
1514
1569
|
method: 'POST',
|
|
1515
1570
|
body: JSON.stringify({ answer, sdk_session_id: sessionId || undefined }),
|
|
1571
|
+
onErrorResponse,
|
|
1516
1572
|
});
|
|
1517
1573
|
}
|
|
1518
1574
|
|
|
@@ -14,6 +14,7 @@ import { schemaFieldsFromQuestion } from './question-schema-view.mjs';
|
|
|
14
14
|
import { composeMultiSelectAnswer, isMultiSelectQuestion, offersMultiSelectToggle } from './question-multi-select.mjs';
|
|
15
15
|
import { isChatInteractionHeld } from './selection-guard.mjs';
|
|
16
16
|
import { questionCardHeadLabel } from './conversation-provider-indicator.mjs';
|
|
17
|
+
import { questionAcceptsFreeText } from './question-answer-rule.mjs';
|
|
17
18
|
import { beginOwnSend, followsTranscriptEnd, refreshJumpButton, scrollAfterOwnSend } from './transcript-jump-bottom-view.mjs';
|
|
18
19
|
|
|
19
20
|
let relayQuestionRenderHash = '';
|
|
@@ -265,6 +266,10 @@ export function renderRelayQuestions() {
|
|
|
265
266
|
const modeTag = question.mode ? ` <span class="msg-mode">${escHtml(question.mode)}</span>` : '';
|
|
266
267
|
const contextText = String(question?.context?.rationale || '').trim();
|
|
267
268
|
const contextHtml = contextText ? `<div class="relay-question-context">${renderLinkedPlainText(contextText)}</div>` : '';
|
|
269
|
+
// The card's own title ("Agent sessions", "Remote relay", a Claude
|
|
270
|
+
// question's header), above the question.
|
|
271
|
+
const headerText = String(question?.context?.header || '').trim();
|
|
272
|
+
const headerHtml = headerText ? `<div class="relay-question-header">${escHtml(headerText)}</div>` : '';
|
|
268
273
|
const fields = schemaFieldsFromQuestion(question);
|
|
269
274
|
const structuredQuestion = fields.length > 0;
|
|
270
275
|
|
|
@@ -296,14 +301,21 @@ export function renderRelayQuestions() {
|
|
|
296
301
|
choices.map((choice) => `<button class="relay-question-choice" disabled>${escHtml(choice)}</button>`).join('')
|
|
297
302
|
}</div>`;
|
|
298
303
|
}
|
|
299
|
-
|
|
304
|
+
// A card that takes no typed text (approval and permission cards) is
|
|
305
|
+
// answered by its choices alone: no reply box, and a multi-select one
|
|
306
|
+
// keeps only its "Reply with selection".
|
|
307
|
+
const freeText = questionAcceptsFreeText(question);
|
|
308
|
+
const showReplyControls = question.status === 'pending' && (freeText || multiSelect);
|
|
300
309
|
const draftText = String(relayQuestionDrafts.get(question.id) || '');
|
|
301
310
|
const replyPlaceholder = multiSelect
|
|
302
311
|
? 'Add another answer or a note (optional)… (Ctrl/Cmd+Enter to send)'
|
|
303
312
|
: 'Type a reply… (Ctrl/Cmd+Enter to send)';
|
|
313
|
+
const replyInputHtml = freeText
|
|
314
|
+
? `<textarea id="relay-question-input-${question.id}" placeholder="${replyPlaceholder}" onkeydown="handleRelayQuestionKey(event, '${question.id}')" oninput="onRelayQuestionDraftInput('${question.id}', this.value)">${escHtml(draftText)}</textarea>`
|
|
315
|
+
: '';
|
|
304
316
|
const replyHtml = showReplyControls
|
|
305
317
|
? `<div class="relay-question-reply">
|
|
306
|
-
|
|
318
|
+
${replyInputHtml}
|
|
307
319
|
<button class="relay-question-submit" onclick="${multiSelect ? 'submitRelayQuestionMultiSelect' : 'submitRelayQuestionAnswer'}('${question.id}')">${multiSelect ? 'Reply with selection' : 'Reply'}</button>
|
|
308
320
|
</div>`
|
|
309
321
|
: '';
|
|
@@ -320,6 +332,7 @@ export function renderRelayQuestions() {
|
|
|
320
332
|
wrapper.innerHTML = `
|
|
321
333
|
<div class="relay-question-card${question.status === 'timed_out' ? ' relay-question-timed-out' : ''}">
|
|
322
334
|
<div class="relay-question-head">${questionCardHeadLabel(conversations[question.conversationId])}${modeTag} · ${fmtDate(question.createdAt)}</div>
|
|
335
|
+
${headerHtml}
|
|
323
336
|
<div class="relay-question-body">${renderLinkedPlainText(question.prompt || '')}</div>
|
|
324
337
|
${contextHtml}
|
|
325
338
|
${answeredHtml}
|
|
@@ -580,6 +593,15 @@ async function enqueueRelayAnswerForSync(questionId, body) {
|
|
|
580
593
|
return queued;
|
|
581
594
|
}
|
|
582
595
|
|
|
596
|
+
// The relay's reason for refusing an answer (a card that takes only its
|
|
597
|
+
// choices names them), or '' when it gave none.
|
|
598
|
+
function relayAnswerRefusalText(refusal) {
|
|
599
|
+
const reason = String(refusal?.error || '').trim();
|
|
600
|
+
if (!reason) return '';
|
|
601
|
+
const choices = Array.isArray(refusal?.choices) ? refusal.choices.map((choice) => String(choice)).filter(Boolean) : [];
|
|
602
|
+
return choices.length ? `${reason}. Choices: ${choices.join(', ')}.` : reason;
|
|
603
|
+
}
|
|
604
|
+
|
|
583
605
|
export async function submitRelayQuestionAnswer(questionId, presetAnswer = null) {
|
|
584
606
|
const input = document.getElementById(`relay-question-input-${questionId}`);
|
|
585
607
|
// Ctrl/Cmd+Enter in a multi-select card's text field sends the ticked
|
|
@@ -600,15 +622,18 @@ export async function submitRelayQuestionAnswer(questionId, presetAnswer = null)
|
|
|
600
622
|
const question = relayQuestions.get(questionId) || null;
|
|
601
623
|
const sdkSessionId = String(question?.sdkSessionId || '').trim();
|
|
602
624
|
const singleFieldStructuredAnswer = resolveSingleFieldStructuredAnswer(question, answer);
|
|
625
|
+
let refusal = null;
|
|
603
626
|
const r = singleFieldStructuredAnswer
|
|
604
627
|
? await answerRelayQuestionStructured(questionId, singleFieldStructuredAnswer, sdkSessionId || null)
|
|
605
|
-
: await answerRelayQuestion(questionId, answer, sdkSessionId || null
|
|
628
|
+
: await answerRelayQuestion(questionId, answer, sdkSessionId || null, {
|
|
629
|
+
onErrorResponse: async (response) => { refusal = await response.json().catch(() => null); },
|
|
630
|
+
});
|
|
606
631
|
if (!r?.question) {
|
|
607
632
|
const queued = await enqueueRelayAnswerForSync(questionId, singleFieldStructuredAnswer
|
|
608
633
|
? { structuredAnswer: singleFieldStructuredAnswer, sdk_session_id: sdkSessionId || undefined }
|
|
609
634
|
: { answer, sdk_session_id: sdkSessionId || undefined });
|
|
610
635
|
if (queued) return;
|
|
611
|
-
throw new Error('Failed to submit relay question answer');
|
|
636
|
+
throw new Error(relayAnswerRefusalText(refusal) || 'Failed to submit relay question answer');
|
|
612
637
|
}
|
|
613
638
|
relayQuestionDrafts.delete(questionId);
|
|
614
639
|
relayQuestionStructuredDrafts.delete(questionId);
|
|
@@ -12,13 +12,16 @@ import { apiFetch } from './api-client.js';
|
|
|
12
12
|
import { formatTaskModelLabel, formatTaskTokens } from './task-row-format.mjs';
|
|
13
13
|
import { prefixToolActivityEmoji } from './tool-activity-emoji.mjs';
|
|
14
14
|
import {
|
|
15
|
+
TASK_OUTPUT_NONE_MESSAGES,
|
|
15
16
|
endedOutcome,
|
|
16
17
|
normalizeStallThreshold,
|
|
18
|
+
pipeHoldNotice,
|
|
17
19
|
runnerChip,
|
|
18
20
|
stallHint,
|
|
19
21
|
stepsLabel,
|
|
20
22
|
taskHasConsole,
|
|
21
23
|
taskOutputUnavailable,
|
|
24
|
+
taskPipeHold,
|
|
22
25
|
} from './task-console-render.mjs';
|
|
23
26
|
|
|
24
27
|
// Shared with the sub-agent rows (subagent-row.mjs); re-exported for the
|
|
@@ -52,6 +55,12 @@ const stopsInFlight = new Set();
|
|
|
52
55
|
const workflowFoldState = new Map();
|
|
53
56
|
let currentConversationId = null;
|
|
54
57
|
let elapsedTimer = null;
|
|
58
|
+
// Counts the `background_tasks` events per conversation. A reload's payload
|
|
59
|
+
// is the set as the relay had it when the request was answered; one that was
|
|
60
|
+
// asked for before the latest event can land after it and would put the
|
|
61
|
+
// older set back (the panel shrinking under the reader, then growing again
|
|
62
|
+
// with the next poll).
|
|
63
|
+
const taskEventCounts = new Map();
|
|
55
64
|
|
|
56
65
|
const TASK_TYPE_ICONS = {
|
|
57
66
|
local_bash: '⌨️',
|
|
@@ -363,14 +372,35 @@ function notifyChange(conversationId) {
|
|
|
363
372
|
}
|
|
364
373
|
}
|
|
365
374
|
|
|
375
|
+
/**
|
|
376
|
+
* Taken before a reload of the conversation is asked for, and handed to
|
|
377
|
+
* isBackgroundTasksSnapshotCurrent() with its answer.
|
|
378
|
+
*/
|
|
379
|
+
export function backgroundTasksSnapshotMark(conversationId) {
|
|
380
|
+
return taskEventCounts.get(String(conversationId || '').trim()) || 0;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Whether a reload's sets (`backgroundTasks`, `endedBackgroundTasks`) may
|
|
385
|
+
* replace the conversation's on the page: no `background_tasks` event for it
|
|
386
|
+
* arrived since `mark` was taken. Otherwise the event's set is at least as
|
|
387
|
+
* new, and the next reload brings whatever came after it. Without a mark:
|
|
388
|
+
* always.
|
|
389
|
+
*/
|
|
390
|
+
export function isBackgroundTasksSnapshotCurrent(conversationId, mark) {
|
|
391
|
+
return mark === undefined || mark === null || mark === backgroundTasksSnapshotMark(conversationId);
|
|
392
|
+
}
|
|
393
|
+
|
|
366
394
|
/**
|
|
367
395
|
* The live set (REPLACE semantics). `endedTasks`, when the event or payload
|
|
368
396
|
* carries it, replaces the ended set too; an older relay's event without it
|
|
369
|
-
* leaves the ended rows alone.
|
|
397
|
+
* leaves the ended rows alone. `event`: it came as a `background_tasks`
|
|
398
|
+
* event (see isBackgroundTasksSnapshotCurrent).
|
|
370
399
|
*/
|
|
371
|
-
export function setConversationBackgroundTasks(conversationId, tasks, { endedTasks } = {}) {
|
|
400
|
+
export function setConversationBackgroundTasks(conversationId, tasks, { endedTasks, event = false } = {}) {
|
|
372
401
|
const id = String(conversationId || '').trim();
|
|
373
402
|
if (!id) return;
|
|
403
|
+
if (event) taskEventCounts.set(id, backgroundTasksSnapshotMark(id) + 1);
|
|
374
404
|
const normalized = normalizeTaskList(tasks);
|
|
375
405
|
// Drop fold choices for tasks that left this conversation's set so the map
|
|
376
406
|
// cannot grow across long sessions.
|
|
@@ -460,6 +490,20 @@ export function findBackgroundTask(conversationId, taskId) {
|
|
|
460
490
|
return ended ? { task: ended, ended: true } : null;
|
|
461
491
|
}
|
|
462
492
|
|
|
493
|
+
const panelRenderListeners = new Set();
|
|
494
|
+
|
|
495
|
+
/** Called after the panel drew its rows (the task console keeps above its row). */
|
|
496
|
+
export function onBackgroundTasksPanelRendered(listener) {
|
|
497
|
+
panelRenderListeners.add(listener);
|
|
498
|
+
return () => panelRenderListeners.delete(listener);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function notifyPanelRendered() {
|
|
502
|
+
for (const listener of panelRenderListeners) {
|
|
503
|
+
try { listener(); } catch (error) { console.warn('background task panel listener failed', error?.message || error); }
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
463
507
|
/** Called with the conversation id after any change of tasks or health. */
|
|
464
508
|
export function subscribeBackgroundTasks(listener) {
|
|
465
509
|
changeListeners.add(listener);
|
|
@@ -538,7 +582,7 @@ function taskTitleHtml(task) {
|
|
|
538
582
|
return `<button type="button" class="subagent-open bg-task-desc" data-subagent-open="${escHtml(runId)}" title="Open this sub-agent's thoughts, tool calls and reply">${text}</button>`;
|
|
539
583
|
}
|
|
540
584
|
if (taskOutputUnavailable(task)) {
|
|
541
|
-
return `<span class="bg-task-desc bg-task-desc-no-output" title="
|
|
585
|
+
return `<span class="bg-task-desc bg-task-desc-no-output" title="${escHtml(TASK_OUTPUT_NONE_MESSAGES.provider)}">${text}</span>`;
|
|
542
586
|
}
|
|
543
587
|
if (taskHasConsole(task)) {
|
|
544
588
|
return `<button type="button" class="task-console-open bg-task-desc" data-task-console="${escHtml(task.taskId)}" title="Open this task's output">${text}</button>`;
|
|
@@ -553,10 +597,13 @@ function chipHtml(health) {
|
|
|
553
597
|
}
|
|
554
598
|
|
|
555
599
|
// Always present on a live row (hidden while output is fresh) so the 1 s
|
|
556
|
-
// timer can update it in place.
|
|
557
|
-
|
|
600
|
+
// timer can update it in place. A command piped into tail is quiet
|
|
601
|
+
// until it ends: its hover title says so.
|
|
602
|
+
function stallHtml(hint, task) {
|
|
558
603
|
const classes = `bg-task-stall${hint?.warn ? ' bg-task-stall-warn' : ''}`;
|
|
559
|
-
|
|
604
|
+
const notice = pipeHoldNotice(taskPipeHold(task));
|
|
605
|
+
const title = notice ? ` title="${escHtml(notice.title)}"` : '';
|
|
606
|
+
return `<span class="${classes}"${title}${hint ? '' : ' hidden'}>${hint ? escHtml(hint.text) : ''}</span>`;
|
|
560
607
|
}
|
|
561
608
|
|
|
562
609
|
function taskSteps(task) {
|
|
@@ -613,7 +660,7 @@ export function renderTaskRow(conversationId, task) {
|
|
|
613
660
|
${tokens ? `<span class="bg-task-tokens">${escHtml(tokens)}</span>` : ''}
|
|
614
661
|
${steps ? `<span class="bg-task-steps">${escHtml(steps)}</span>` : ''}
|
|
615
662
|
${chipHtml(health)}
|
|
616
|
-
${taskHasConsole(task) ? stallHtml(stall) : ''}
|
|
663
|
+
${taskHasConsole(task) ? stallHtml(stall, task) : ''}
|
|
617
664
|
<span class="bg-task-elapsed">${escHtml(elapsed)}</span>
|
|
618
665
|
${task.stoppable === false ? '' : `<button type="button" class="bg-task-stop" data-task-id="${escHtml(task.taskId)}" ${stopping ? 'disabled' : ''}>${stopping ? 'Stopping…' : 'Stop'}</button>`}
|
|
619
666
|
</span>
|
|
@@ -684,6 +731,7 @@ export function renderBackgroundTasksPanel() {
|
|
|
684
731
|
const staleList = panel.querySelector('#background-tasks-list');
|
|
685
732
|
if (staleList) staleList.innerHTML = '';
|
|
686
733
|
stopElapsedTimer();
|
|
734
|
+
notifyPanelRendered();
|
|
687
735
|
return;
|
|
688
736
|
}
|
|
689
737
|
const wasOpen = panel.hasAttribute('open');
|
|
@@ -733,6 +781,7 @@ export function renderBackgroundTasksPanel() {
|
|
|
733
781
|
if (wasOpen) panel.setAttribute('open', '');
|
|
734
782
|
if (tasks.length) startElapsedTimer();
|
|
735
783
|
else stopElapsedTimer();
|
|
784
|
+
notifyPanelRendered();
|
|
736
785
|
}
|
|
737
786
|
|
|
738
787
|
// A preview created, closed, or flipping online/offline re-renders the panel;
|
|
@@ -149,6 +149,7 @@ import {
|
|
|
149
149
|
canMergeIntoTranscript,
|
|
150
150
|
} from './conversation-view.js';
|
|
151
151
|
import { loadKeepingLiveRows } from './transcript-reload-freshness.mjs';
|
|
152
|
+
import { backgroundTasksSnapshotMark } from './background-tasks-view.mjs';
|
|
152
153
|
import { newerPageReachesEnd } from './transcript-island.mjs';
|
|
153
154
|
import { initPinnedMessagesView, setPinsConversation } from './pinned-messages-view.mjs';
|
|
154
155
|
import { initHiddenMessagesView, setHiddenMessagesConversation } from './hidden-messages-view.mjs';
|
|
@@ -339,6 +340,7 @@ import {
|
|
|
339
340
|
updateBackgroundTaskTimeoutSetting,
|
|
340
341
|
previewTaskStallMinutesSetting,
|
|
341
342
|
updateTaskStallMinutesSetting,
|
|
343
|
+
updateRestartResumeSettingFromToggle,
|
|
342
344
|
updateSubagentRecordRetentionSetting,
|
|
343
345
|
} from './settings-modal.js';
|
|
344
346
|
import {
|
|
@@ -3758,6 +3760,7 @@ async function pollAuthenticatedCurrentConversationLive() {
|
|
|
3758
3760
|
const savedScrollTop = messagesEl?.scrollTop || 0;
|
|
3759
3761
|
const savedLoadedCount = loadConversationLoadedMessageCount(currentId);
|
|
3760
3762
|
const requestLimit = Math.max(20, getConversationLoadedMessageCount() || 0, savedLoadedCount || 0);
|
|
3763
|
+
const tasksMark = backgroundTasksSnapshotMark(currentId);
|
|
3761
3764
|
const jumpEpoch = getTranscriptJumpEpoch();
|
|
3762
3765
|
const { response, stale } = await loadKeepingLiveRows(
|
|
3763
3766
|
() => loadConversation(currentId, { limit: requestLimit }),
|
|
@@ -3783,6 +3786,7 @@ async function pollAuthenticatedCurrentConversationLive() {
|
|
|
3783
3786
|
savedScrollTop: preserveBottom ? null : stableSavedScrollTop,
|
|
3784
3787
|
followLiveUpdates: preserveBottom,
|
|
3785
3788
|
mergeFrom: merge?.mergeFrom || null,
|
|
3789
|
+
backgroundTasksAsOf: tasksMark,
|
|
3786
3790
|
});
|
|
3787
3791
|
// A merge does not scroll as the full render does: a reader at the end
|
|
3788
3792
|
// is taken to the new end here.
|
|
@@ -4061,6 +4065,7 @@ async function refreshCurrentView() {
|
|
|
4061
4065
|
|
|
4062
4066
|
const savedLoadedCount = loadConversationLoadedMessageCount(currentId);
|
|
4063
4067
|
const requestLimit = Math.max(20, getConversationLoadedMessageCount() || 0, savedLoadedCount || 0);
|
|
4068
|
+
const tasksMark = backgroundTasksSnapshotMark(currentId);
|
|
4064
4069
|
const { response: r, stale } = await loadKeepingLiveRows(
|
|
4065
4070
|
() => loadConversation(currentId, { limit: requestLimit }),
|
|
4066
4071
|
getTranscriptLiveRowEpoch,
|
|
@@ -4085,6 +4090,7 @@ async function refreshCurrentView() {
|
|
|
4085
4090
|
followLiveUpdates: preserveBottom && !keepTranscript,
|
|
4086
4091
|
keepTranscript,
|
|
4087
4092
|
mergeFrom: merge?.mergeFrom || null,
|
|
4093
|
+
backgroundTasksAsOf: tasksMark,
|
|
4088
4094
|
});
|
|
4089
4095
|
// As in the live poll: a merge leaves the scrolling to the caller.
|
|
4090
4096
|
if (merge && preserveBottom && messagesEl) messagesEl.scrollTop = messagesEl.scrollHeight;
|
|
@@ -5102,6 +5108,7 @@ window.previewBackgroundTaskTimeoutSetting = previewBackgroundTaskTimeoutSetting
|
|
|
5102
5108
|
window.updateBackgroundTaskTimeoutSetting = updateBackgroundTaskTimeoutSetting;
|
|
5103
5109
|
window.previewTaskStallMinutesSetting = previewTaskStallMinutesSetting;
|
|
5104
5110
|
window.updateTaskStallMinutesSetting = updateTaskStallMinutesSetting;
|
|
5111
|
+
window.updateRestartResumeSettingFromToggle = updateRestartResumeSettingFromToggle;
|
|
5105
5112
|
window.updateSubagentRecordRetentionSetting = updateSubagentRecordRetentionSetting;
|
|
5106
5113
|
window.togglePushOnThisDevice = togglePushOnThisDevice;
|
|
5107
5114
|
window.updatePushPreferencesFromControls = updatePushPreferencesFromControls;
|
|
@@ -89,11 +89,14 @@ function normalizeEffortList(efforts = []) {
|
|
|
89
89
|
* 3. else the lowest supported rung ABOVE it, ignoring 'none'
|
|
90
90
|
* (a remembered 'minimal' on a model whose floor is 'low' → low);
|
|
91
91
|
* 4. else the next candidate.
|
|
92
|
-
* With no candidate usable: the lowest non-'none' rung the model offers
|
|
93
|
-
* 'none' when that is all there is
|
|
94
|
-
*
|
|
92
|
+
* With no candidate usable: the lowest non-'none' rung the model offers (by
|
|
93
|
+
* rung, whatever the list order), or 'none' when that is all there is; a
|
|
94
|
+
* list with names off the ladder keeps its own order there. A caller with a
|
|
95
|
+
* default of its own passes `fallback`, returned instead in that case.
|
|
96
|
+
* Effort names off the ladder (image quality values, unknown providers) only
|
|
97
|
+
* ever match exactly.
|
|
95
98
|
*/
|
|
96
|
-
export function nearestSupportedReasoningEffort(candidates = [], supportedEfforts = []) {
|
|
99
|
+
export function nearestSupportedReasoningEffort(candidates = [], supportedEfforts = [], { fallback } = {}) {
|
|
97
100
|
const options = normalizeEffortList(supportedEfforts);
|
|
98
101
|
if (!options.length) return '';
|
|
99
102
|
const rungOf = (effort) => REASONING_EFFORT_LADDER.indexOf(effort);
|
|
@@ -109,6 +112,9 @@ export function nearestSupportedReasoningEffort(candidates = [], supportedEffort
|
|
|
109
112
|
const above = ranked.find((option) => rungOf(option) > rung && option !== 'ultracode');
|
|
110
113
|
if (above) return above;
|
|
111
114
|
}
|
|
115
|
+
if (fallback !== undefined) return fallback;
|
|
116
|
+
const lowest = ranked.find((option) => option !== 'ultracode');
|
|
117
|
+
if (lowest && options.every((option) => rungOf(option) >= 0)) return lowest;
|
|
112
118
|
return options.find((option) => option !== 'none') || options[0];
|
|
113
119
|
}
|
|
114
120
|
|
|
@@ -33,15 +33,25 @@ export function isConversationUsingClaudeCloudProvider(conversation = null) {
|
|
|
33
33
|
// The marker that tells a cloud chat from a local Claude one at a glance.
|
|
34
34
|
const CLAUDE_CLOUD_MARKER = '☁';
|
|
35
35
|
|
|
36
|
+
const PROVIDER_NAMES = Object.freeze({
|
|
37
|
+
openai: 'OpenAI',
|
|
38
|
+
claude: 'Claude',
|
|
39
|
+
'claude-cloud': 'Claude Cloud',
|
|
40
|
+
cursor: 'Cursor',
|
|
41
|
+
grok: 'Grok',
|
|
42
|
+
github: 'Copilot',
|
|
43
|
+
'github-copilot': 'Copilot',
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// A provider type's name as the list pill and the question card say it; ''
|
|
47
|
+
// when unknown. The relay's question push uses it too
|
|
48
|
+
// (services/push-dispatch-service.mjs).
|
|
49
|
+
export function providerDisplayName(providerType = '') {
|
|
50
|
+
return PROVIDER_NAMES[String(providerType || '').trim().toLowerCase()] || '';
|
|
51
|
+
}
|
|
52
|
+
|
|
36
53
|
function conversationProviderName(conversation = null) {
|
|
37
|
-
|
|
38
|
-
if (providerType === 'openai') return 'OpenAI';
|
|
39
|
-
if (providerType === 'claude') return 'Claude';
|
|
40
|
-
if (providerType === 'claude-cloud') return 'Claude Cloud';
|
|
41
|
-
if (providerType === 'cursor') return 'Cursor';
|
|
42
|
-
if (providerType === 'grok') return 'Grok';
|
|
43
|
-
if (providerType === 'github' || providerType === 'github-copilot') return 'Copilot';
|
|
44
|
-
return '';
|
|
54
|
+
return providerDisplayName(resolveConversationProviderType(conversation));
|
|
45
55
|
}
|
|
46
56
|
|
|
47
57
|
// An unresolved provider stays unlabelled rather than defaulting to Copilot, so
|
|
@@ -67,6 +67,7 @@ import { closeSlashAutocomplete, handleSlashAutocompleteKey, updateSlashAutocomp
|
|
|
67
67
|
import { closeMentionAutocomplete, handleMentionAutocompleteKey, updateMentionAutocomplete } from './mention-autocomplete.mjs';
|
|
68
68
|
import { renderRemoteOriginBadgeHtml } from './remote-relay-origin-view.mjs';
|
|
69
69
|
import { isRelayEventMessage, renderRelayEventRowHtml } from './relay-event-view.mjs';
|
|
70
|
+
import { isRestartResumeNote, renderRestartResumeNoteHtml } from './relay-restart-note-view.mjs';
|
|
70
71
|
import {
|
|
71
72
|
AGENT_EXCHANGE_CLOSED_CLASS,
|
|
72
73
|
AGENT_EXCHANGE_HEAD_CLASS,
|
|
@@ -398,14 +399,18 @@ const conversationHistoryLoader = createInfiniteLoader({
|
|
|
398
399
|
syncSteeredTurnMerge(el);
|
|
399
400
|
// The page's agent exchanges fold before the height delta is measured.
|
|
400
401
|
syncAgentExchangeFolds(el);
|
|
402
|
+
// Here, not a frame later: the position taken above is the reader's only
|
|
403
|
+
// until they scroll again, and a scroll in that frame (a jump to the end
|
|
404
|
+
// and up again) was undone by it.
|
|
405
|
+
el.scrollTop = previousScrollTop + (el.scrollHeight - previousScrollHeight);
|
|
406
|
+
// The page's images have no height yet: the place read is held while
|
|
407
|
+
// they come in, as after a restore.
|
|
408
|
+
const place = captureConversationReadingAnchor();
|
|
409
|
+
if (place?.conversationId === currentId) holdReadingAnchorWhileMediaLoads(el, place.anchor);
|
|
410
|
+
// Once the loader has taken this page's cursor: still near the top, the
|
|
411
|
+
// next page loads.
|
|
401
412
|
requestAnimationFrame(() => {
|
|
402
|
-
if (
|
|
403
|
-
const nextScrollHeight = el.scrollHeight;
|
|
404
|
-
el.scrollTop = previousScrollTop + (nextScrollHeight - previousScrollHeight);
|
|
405
|
-
// The page's images have no height yet: the place read is held while
|
|
406
|
-
// they come in, as after a restore.
|
|
407
|
-
const place = captureConversationReadingAnchor();
|
|
408
|
-
if (place?.conversationId === currentId) holdReadingAnchorWhileMediaLoads(el, place.anchor);
|
|
413
|
+
if (String(currentConvId || '').trim() !== currentId) return;
|
|
409
414
|
void conversationHistoryLoader.handleBoundaryDistance(el.scrollTop);
|
|
410
415
|
});
|
|
411
416
|
},
|
|
@@ -1336,6 +1341,21 @@ function createRelayEventNode(msg, msgId) {
|
|
|
1336
1341
|
return div;
|
|
1337
1342
|
}
|
|
1338
1343
|
|
|
1344
|
+
// The relay's note to carry on after a restart cut a turn off: a folded row
|
|
1345
|
+
// like an event row (relay-restart-note-view.mjs).
|
|
1346
|
+
function createRestartResumeNoteNode(msg, msgId) {
|
|
1347
|
+
const div = document.createElement('div');
|
|
1348
|
+
div.className = 'msg relay-event relay-restart-resume';
|
|
1349
|
+
if (msgId) div.dataset.messageId = msgId;
|
|
1350
|
+
const fingerprint = buildLiveMessageFingerprint({ ...msg, id: msgId || msg?.id || '' });
|
|
1351
|
+
div.dataset.messageRole = fingerprint.role || '';
|
|
1352
|
+
div.dataset.messageTextFingerprint = fingerprint.text || '';
|
|
1353
|
+
div.dataset.messageTimestamp = String(msg?.timestamp || '').trim();
|
|
1354
|
+
div.innerHTML = renderRestartResumeNoteHtml(msg, { escHtml, timeText: msg?.timestamp ? fmtDate(msg.timestamp) : '' });
|
|
1355
|
+
setAgentExchangeRow(div, msg, msgId);
|
|
1356
|
+
return div;
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1339
1359
|
// Agent exchanges (agent-exchange-fold-view.mjs): an agent's prompt folds
|
|
1340
1360
|
// into one row; this session's reply to it always shows. What the fold pass
|
|
1341
1361
|
// needs of a message is kept on its node when the row is built, so the pass
|
|
@@ -1469,6 +1489,7 @@ function createMessageNode(msg, msgId = null, force = false) {
|
|
|
1469
1489
|
// An event message the relay wrote into a watcher's conversation: a
|
|
1470
1490
|
// compact, foldable row, never a bubble of the user's.
|
|
1471
1491
|
if (isRelayEventMessage(msg)) return createRelayEventNode(msg, msgId);
|
|
1492
|
+
if (isRestartResumeNote(msg)) return createRestartResumeNoteNode(msg, msgId);
|
|
1472
1493
|
|
|
1473
1494
|
// A prompt another relay's agent sent carries `origin`: its header line is
|
|
1474
1495
|
// hidden (the badge below says the same), and a stray remote-relay mention
|
|
@@ -4602,7 +4623,10 @@ export async function sendMessage() {
|
|
|
4602
4623
|
: undefined,
|
|
4603
4624
|
};
|
|
4604
4625
|
|
|
4605
|
-
|
|
4626
|
+
// A refusal the user can act on (the conversation's model left the
|
|
4627
|
+
// catalog) is shown as the relay words it.
|
|
4628
|
+
let refusal = '';
|
|
4629
|
+
const r = await sendMessageApi(body, { onRefused: (reason) => { refusal = reason; } });
|
|
4606
4630
|
if (!r) {
|
|
4607
4631
|
// Offline: park the send in the durable outbox instead of bouncing it
|
|
4608
4632
|
// back into the composer. The client-generated messageId makes a replay
|
|
@@ -4636,7 +4660,7 @@ export async function sendMessage() {
|
|
|
4636
4660
|
sendLanded,
|
|
4637
4661
|
});
|
|
4638
4662
|
if (!mobileSend && viewingSendConversation()) input.focus();
|
|
4639
|
-
setModelBanner('⚠️ Message could not be sent. Please try again.');
|
|
4663
|
+
setModelBanner(refusal ? `⚠️ ${refusal}` : '⚠️ Message could not be sent. Please try again.');
|
|
4640
4664
|
return;
|
|
4641
4665
|
}
|
|
4642
4666
|
|