@oar-sh/oar 0.9.3 → 0.9.5
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 +6 -1
- package/.github/extensions/web-relay/skills/tool-activity.mjs +7 -0
- package/CHANGELOG.md +98 -0
- package/README.md +76 -14
- package/package.json +5 -2
- package/server/README.md +43 -3
- package/server/claude-worker/claude-remote-relay-tool.mjs +100 -0
- package/server/claude-worker/claude-sdk-adapter.mjs +26 -4
- package/server/claude-worker/claude-session-process.mjs +385 -43
- package/server/claude-worker/claude-session-worker.mjs +5 -0
- package/server/claude-worker/sdk-message-normalizer.mjs +7 -0
- package/server/copilot-worker/copilot-remote-relay-tool.mjs +59 -0
- package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +12 -2
- package/server/copilot-worker/copilot-sdk-session-process.mjs +83 -9
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +5 -0
- package/server/cursor-worker/cursor-remote-relay-tool.mjs +65 -0
- package/server/cursor-worker/cursor-session-worker.mjs +5 -0
- package/server/cursor-worker/cursor-turn-runner.mjs +45 -2
- package/server/cursor-worker/sdk-message-normalizer.mjs +25 -0
- package/server/db-schema.mjs +8 -0
- package/server/grok-worker/acp-client.mjs +92 -6
- package/server/grok-worker/grok-session-worker.mjs +5 -0
- package/server/grok-worker/grok-turn-runner.mjs +59 -8
- package/server/grok-worker/sdk-message-normalizer.mjs +13 -0
- package/server/mcp/oar-mcp-server.mjs +473 -0
- package/server/migrations/0005-remote-relays.mjs +90 -0
- package/server/public/app/action-confirmations.js +141 -13
- package/server/public/app/api-client.js +103 -0
- package/server/public/app/bootstrap.js +21 -6
- package/server/public/app/conversation-deep-link.mjs +42 -0
- package/server/public/app/conversation-view.js +100 -7
- package/server/public/app/host-suspend-ui.js +272 -0
- package/server/public/app/journal-view.js +4 -1
- package/server/public/app/mention-autocomplete.mjs +298 -0
- package/server/public/app/push-settings.js +1 -0
- package/server/public/app/pwa-install.js +8 -3
- package/server/public/app/relay-prompt-sanitizer.mjs +48 -5
- package/server/public/app/remote-relay-origin-view.mjs +76 -0
- package/server/public/app/remote-relay-shared.mjs +85 -0
- package/server/public/app/remote-relays-settings.js +618 -0
- package/server/public/app/remote-relays-store.mjs +126 -0
- package/server/public/app/settings-modal.js +4 -2
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/socket-handlers.js +18 -1
- package/server/public/app/steered-turn-merge.mjs +10 -1
- package/server/public/app/thinking-follow.js +260 -0
- package/server/public/app/tool-activity-emoji.mjs +4 -0
- package/server/public/index.html +264 -11
- package/server/relay-tools.md +5 -1
- package/server/repositories/message-repository.mjs +7 -0
- package/server/repositories/question-repository.mjs +3 -0
- package/server/repositories/remote-relay-repository.mjs +75 -0
- package/server/repositories/session-repository.mjs +6 -1
- package/server/routes/ask-user-routes.mjs +42 -2
- package/server/routes/messages-routes.mjs +98 -19
- package/server/routes/remote-relay-routes.mjs +133 -0
- package/server/routes/sessions-routes.mjs +84 -31
- package/server/server-runtime.mjs +238 -0
- package/server/services/github-ci-activity-service.mjs +199 -0
- package/server/services/host-suspend-command.mjs +38 -0
- package/server/services/host-suspend-service.mjs +254 -0
- package/server/services/push-dispatch-service.mjs +37 -4
- package/server/services/remote-relay-caller-context.mjs +346 -0
- package/server/services/remote-relay-client.mjs +332 -0
- package/server/services/remote-relay-dispatcher.mjs +1743 -0
- package/server/services/remote-relay-inbound.mjs +187 -0
- package/server/services/remote-relay-pairing.mjs +189 -0
- package/server/services/remote-relay-registry-service.mjs +581 -0
- package/server/services/session-worker-launch-service.mjs +148 -14
- package/shared/remote-relay-contract.mjs +556 -0
- package/shared/remote-relay-mentions.mjs +129 -0
- package/shared/remote-relay-tool-core.mjs +314 -0
- package/shared/steer-settle-failure.mjs +6 -0
- package/shared/steer-settle-markers.mjs +16 -0
- package/shared/worker-runtime/api-client.mjs +112 -22
- package/shared/worker-runtime/worker-log-file.mjs +97 -0
|
@@ -45,6 +45,8 @@ import {
|
|
|
45
45
|
imageEditTarget,
|
|
46
46
|
setImageEditTarget as setStoredImageEditTarget,
|
|
47
47
|
getSessionWorkerState,
|
|
48
|
+
openSummaryModal,
|
|
49
|
+
closeSummaryModal,
|
|
48
50
|
} from './store.js';
|
|
49
51
|
import { getPendingQuestionCountsByConversation } from './ask-user-view.js';
|
|
50
52
|
import { sendMessage as sendMessageApi, cancelConversationTurn, cancelQueuedConversationTurn, cancelSubagentRun, compactConversation as compactConversationApi, scheduleContextUsageRefresh, loadConversation as loadConversationApi, loadSharedConversation, updateConversationDraft as updateConversationDraftApi, updateMessageShareVisibility } from './api-client.js';
|
|
@@ -55,6 +57,9 @@ import { buildWorkflowRunCard } from './background-tasks-view.mjs';
|
|
|
55
57
|
import { parsePreviewCommand, runPreviewCommand } from './preview-command.mjs';
|
|
56
58
|
import { buildTranscriptPreviewCard } from './preview-cards.mjs';
|
|
57
59
|
import { closeSlashAutocomplete, handleSlashAutocompleteKey, updateSlashAutocomplete } from './slash-autocomplete.mjs';
|
|
60
|
+
import { closeMentionAutocomplete, handleMentionAutocompleteKey, updateMentionAutocomplete } from './mention-autocomplete.mjs';
|
|
61
|
+
import { renderRemoteOriginBadgeHtml } from './remote-relay-origin-view.mjs';
|
|
62
|
+
import { stripRemoteRelayDisplayArtifacts } from './relay-prompt-sanitizer.mjs';
|
|
58
63
|
import { evaluateUnknownCommandGuard } from './slash-commands.mjs';
|
|
59
64
|
import { attachCodeCopyButtons } from './code-copy.mjs';
|
|
60
65
|
import { relayErrorCtaActions } from './relay-error-ctas.mjs';
|
|
@@ -102,6 +107,16 @@ import {
|
|
|
102
107
|
shouldApplyIncomingDraftToComposer,
|
|
103
108
|
} from './conversation-draft-sync.mjs';
|
|
104
109
|
import { isChatInteractionHeld, selectionIntersectsNode } from './selection-guard.mjs';
|
|
110
|
+
import {
|
|
111
|
+
applyThinkingFollow,
|
|
112
|
+
getThinkingFollowMode,
|
|
113
|
+
initThinkingFollow,
|
|
114
|
+
observeThinkingBubble,
|
|
115
|
+
renderThinkingFollowButtonsHtml,
|
|
116
|
+
syncThinkingFollowButtons,
|
|
117
|
+
toggleThinkingFollowMode,
|
|
118
|
+
unobserveThinkingBubble,
|
|
119
|
+
} from './thinking-follow.js';
|
|
105
120
|
import { buildInFlightSnapshotKey } from './in-flight-snapshot.mjs';
|
|
106
121
|
import { computeStablePrefixLength, planListPatch } from './streaming-dom-patch.mjs';
|
|
107
122
|
import { prefixToolActivityEmoji } from './tool-activity-emoji.mjs';
|
|
@@ -1212,8 +1227,18 @@ function createMessageNode(msg, msgId = null, force = false) {
|
|
|
1212
1227
|
seenMessageIds.add(msgId);
|
|
1213
1228
|
}
|
|
1214
1229
|
|
|
1230
|
+
// A prompt another relay's agent sent carries `origin`: its header line is
|
|
1231
|
+
// hidden (the badge below says the same), and a stray remote-relay mention
|
|
1232
|
+
// hint never shows on any user bubble.
|
|
1233
|
+
const remoteOrigin = (msg?.role === 'user' && msg?.origin && typeof msg.origin === 'object') ? msg.origin : null;
|
|
1234
|
+
if (msg?.role === 'user') {
|
|
1235
|
+
const displayText = stripRemoteRelayDisplayArtifacts(msg.text, { origin: remoteOrigin });
|
|
1236
|
+
if (displayText !== String(msg.text ?? '')) msg = { ...msg, text: displayText };
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1215
1239
|
const div = document.createElement('div');
|
|
1216
1240
|
div.className = `msg ${msg.role}`;
|
|
1241
|
+
if (remoteOrigin) div.classList.add('msg-from-remote-relay');
|
|
1217
1242
|
if (msgId) div.dataset.messageId = msgId;
|
|
1218
1243
|
const fingerprint = buildLiveMessageFingerprint({
|
|
1219
1244
|
...(msg && typeof msg === 'object' ? msg : {}),
|
|
@@ -1252,6 +1277,8 @@ function createMessageNode(msg, msgId = null, force = false) {
|
|
|
1252
1277
|
// The settle marker of a steer the CLI consumed without a turn of its own:
|
|
1253
1278
|
// folded into (and answered by) the turn above, or cut off unanswered by a
|
|
1254
1279
|
// Stop. Compact, attached to its own user message, never merging forward.
|
|
1280
|
+
// Also the marker of a message answered on a continuation row, which only
|
|
1281
|
+
// shares the look.
|
|
1255
1282
|
const steerMarkerClass = msg.role === 'assistant' ? STEER_MARKER_CLASS_BY_KIND[messageKind] : null;
|
|
1256
1283
|
if (steerMarkerClass) div.classList.add(steerMarkerClass);
|
|
1257
1284
|
const isStoppedSteer = msg.role === 'assistant' && messageKind === 'stopped';
|
|
@@ -1376,8 +1403,13 @@ function createMessageNode(msg, msgId = null, force = false) {
|
|
|
1376
1403
|
? `<div class="msg-bubble-actions"><button type="button" class="bubble-action-btn" data-action="resend-stopped-steer" data-message-id="${escHtml(msgId)}" title="Send this message again as a new turn"${resendState ? ' disabled' : ''}>${resendLabel}</button></div>`
|
|
1377
1404
|
: '';
|
|
1378
1405
|
|
|
1406
|
+
// Shared viewers get the badge without the link to the other relay.
|
|
1407
|
+
const originBadgeHtml = remoteOrigin
|
|
1408
|
+
? renderRemoteOriginBadgeHtml(remoteOrigin, { linkable: !IS_SHARED_VIEW })
|
|
1409
|
+
: '';
|
|
1410
|
+
|
|
1379
1411
|
div.innerHTML = `
|
|
1380
|
-
<div class="${bubbleClass}">${shareVisibilityActionHtml}${thoughtsHtml}${content}${relayErrorCtaHtml}${attachmentHtml}${activityHtml}${subagentHtml}${workflowRunsHtml}${previewCardsHtml}${userBubbleActionsHtml}${stoppedSteerActionsHtml}</div>
|
|
1412
|
+
<div class="${bubbleClass}">${shareVisibilityActionHtml}${thoughtsHtml}${originBadgeHtml}${content}${relayErrorCtaHtml}${attachmentHtml}${activityHtml}${subagentHtml}${workflowRunsHtml}${previewCardsHtml}${userBubbleActionsHtml}${stoppedSteerActionsHtml}</div>
|
|
1381
1413
|
<div class="msg-label">${label}${modelTag}${reasoningTag}${modeTag}${autoTag}${continuationTag}${crossProviderTag}${usageTurnTag}${usageRemainingTag}${usageStaleTag} · ${fmtDate(msg.timestamp)}</div>`;
|
|
1382
1414
|
|
|
1383
1415
|
const bubble = div.querySelector('.msg-bubble');
|
|
@@ -1699,10 +1731,14 @@ export function showThinking(messageId = null, autoScroll = true) {
|
|
|
1699
1731
|
stopBtn.classList.toggle('stopping', stopping);
|
|
1700
1732
|
}
|
|
1701
1733
|
anchorThinkingBubble(existing, nextMessageId || existing.dataset.messageId);
|
|
1702
|
-
|
|
1734
|
+
syncThinkingFollowButtons(existing, getThinkingFollowMode());
|
|
1735
|
+
observeThinkingBubble(existing);
|
|
1736
|
+
if (getThinkingFollowMode()) applyThinkingFollow();
|
|
1737
|
+
else if (autoScroll) scrollBottom();
|
|
1703
1738
|
return;
|
|
1704
1739
|
}
|
|
1705
1740
|
existing?.remove();
|
|
1741
|
+
unobserveThinkingBubble();
|
|
1706
1742
|
const div = document.createElement('div');
|
|
1707
1743
|
div.className = 'msg assistant';
|
|
1708
1744
|
div.id = 'thinking-indicator';
|
|
@@ -1712,9 +1748,12 @@ export function showThinking(messageId = null, autoScroll = true) {
|
|
|
1712
1748
|
const stopBtnHtml = (!IS_SHARED_VIEW && nextMessageId)
|
|
1713
1749
|
? `<button type="button" class="bubble-action-btn${isCancelInFlight ? ' stopping' : ''}" data-action="stop-turn" data-message-id="${escHtml(nextMessageId)}"${isCancelInFlight ? ' disabled' : ''}>${isCancelInFlight ? 'Stopping…' : 'Stop'}</button>`
|
|
1714
1750
|
: '';
|
|
1751
|
+
// Stop stays left; the follow buttons (💭 thoughts, 📃 answer, 🛠️ tools)
|
|
1752
|
+
// sit on the right. See thinking-follow.js.
|
|
1753
|
+
const followBtnsHtml = renderThinkingFollowButtonsHtml(getThinkingFollowMode());
|
|
1715
1754
|
div.innerHTML = `
|
|
1716
1755
|
<div class="thinking-bubble">
|
|
1717
|
-
<div class="thinking-bubble-header">${stopBtnHtml}</div>
|
|
1756
|
+
<div class="thinking-bubble-header">${stopBtnHtml}${followBtnsHtml}</div>
|
|
1718
1757
|
<details id="thinking-thoughts" class="thinking-thoughts-panel" open>
|
|
1719
1758
|
<summary>💭 Thoughts</summary>
|
|
1720
1759
|
<div class="thinking-thoughts-list"></div>
|
|
@@ -1727,15 +1766,49 @@ export function showThinking(messageId = null, autoScroll = true) {
|
|
|
1727
1766
|
anchorThinkingBubble(div, nextMessageId);
|
|
1728
1767
|
renderThinkingThoughts();
|
|
1729
1768
|
renderThinkingStream();
|
|
1730
|
-
|
|
1769
|
+
observeThinkingBubble(div);
|
|
1770
|
+
if (getThinkingFollowMode()) applyThinkingFollow();
|
|
1771
|
+
else if (autoScroll) scrollBottom();
|
|
1731
1772
|
}
|
|
1732
1773
|
|
|
1733
1774
|
export function removeThinking() {
|
|
1734
1775
|
thinkingMessageId = null;
|
|
1735
1776
|
lastInFlightSnapshotKey = '';
|
|
1777
|
+
unobserveThinkingBubble();
|
|
1736
1778
|
document.getElementById('thinking-indicator')?.remove();
|
|
1737
1779
|
}
|
|
1738
1780
|
|
|
1781
|
+
// Stop is one tap away on a sticky header, so a stray touch must not kill a
|
|
1782
|
+
// long turn: confirm first, then run the existing stop path.
|
|
1783
|
+
function openStopTurnConfirmation(conversationId, messageId) {
|
|
1784
|
+
const convId = String(conversationId || '').trim();
|
|
1785
|
+
const id = String(messageId || '').trim();
|
|
1786
|
+
if (!convId || !id) return;
|
|
1787
|
+
if (bubbleCancelInFlight.has(id)) return;
|
|
1788
|
+
openSummaryModal({
|
|
1789
|
+
title: 'Stop this turn?',
|
|
1790
|
+
subtitle: 'The agent stops after its current step',
|
|
1791
|
+
kind: 'stop-turn',
|
|
1792
|
+
bodyHtml: `
|
|
1793
|
+
<p>Stop the running turn? Work already done stays; anything the agent was about to do is cut short.</p>
|
|
1794
|
+
<div class="summary-modal-actions">
|
|
1795
|
+
<button class="chat-title-action-btn danger-btn" type="button" data-stop-turn-confirm="1">⏹ Stop turn</button>
|
|
1796
|
+
<button class="chat-title-action-btn" type="button" data-stop-turn-cancel="1">Keep running</button>
|
|
1797
|
+
</div>
|
|
1798
|
+
`,
|
|
1799
|
+
});
|
|
1800
|
+
const body = document.getElementById('summary-modal-body');
|
|
1801
|
+
body?.querySelector('[data-stop-turn-confirm]')?.addEventListener('click', (event) => {
|
|
1802
|
+
event.preventDefault();
|
|
1803
|
+
closeSummaryModal();
|
|
1804
|
+
void stopTurnByMessageId(convId, id);
|
|
1805
|
+
}, { once: true });
|
|
1806
|
+
body?.querySelector('[data-stop-turn-cancel]')?.addEventListener('click', (event) => {
|
|
1807
|
+
event.preventDefault();
|
|
1808
|
+
closeSummaryModal();
|
|
1809
|
+
}, { once: true });
|
|
1810
|
+
}
|
|
1811
|
+
|
|
1739
1812
|
export function collapseThinkingThoughts() {
|
|
1740
1813
|
const panel = document.getElementById('thinking-thoughts');
|
|
1741
1814
|
if (!(panel instanceof HTMLDetailsElement)) return;
|
|
@@ -2919,7 +2992,7 @@ function runRelayErrorCta(cta) {
|
|
|
2919
2992
|
}
|
|
2920
2993
|
|
|
2921
2994
|
function handleBubbleActionClick(event) {
|
|
2922
|
-
const btn = event.target.closest('.bubble-action-btn, .msg-share-visibility-btn');
|
|
2995
|
+
const btn = event.target.closest('.bubble-action-btn, .msg-share-visibility-btn, .thinking-follow-btn');
|
|
2923
2996
|
if (!btn) return;
|
|
2924
2997
|
const action = btn.dataset.action;
|
|
2925
2998
|
const messageId = btn.dataset.messageId;
|
|
@@ -2944,7 +3017,15 @@ function handleBubbleActionClick(event) {
|
|
|
2944
3017
|
if (action === 'stop-turn' && messageId) {
|
|
2945
3018
|
event.preventDefault();
|
|
2946
3019
|
event.stopPropagation();
|
|
2947
|
-
|
|
3020
|
+
openStopTurnConfirmation(currentConvId, messageId);
|
|
3021
|
+
}
|
|
3022
|
+
|
|
3023
|
+
if (action === 'follow-thinking') {
|
|
3024
|
+
event.preventDefault();
|
|
3025
|
+
event.stopPropagation();
|
|
3026
|
+
const mode = toggleThinkingFollowMode(currentConvId, btn.dataset.follow);
|
|
3027
|
+
syncThinkingFollowButtons(document, mode);
|
|
3028
|
+
if (mode) applyThinkingFollow({ force: true });
|
|
2948
3029
|
}
|
|
2949
3030
|
|
|
2950
3031
|
if (action === 'cancel-queued' && messageId) {
|
|
@@ -2997,6 +3078,7 @@ export function initBubbleActionHandlers() {
|
|
|
2997
3078
|
if (!messagesEl) return;
|
|
2998
3079
|
messagesEl.addEventListener('click', handleBubbleActionClick);
|
|
2999
3080
|
messagesEl.addEventListener('click', handleEmbeddedImageClick);
|
|
3081
|
+
initThinkingFollow({ getCurrentConversationId: () => currentConvId });
|
|
3000
3082
|
}
|
|
3001
3083
|
|
|
3002
3084
|
export function appendMessage(msg, scroll = true, msgId = null, force = false, insertAfterId = null, trackHistory = true) {
|
|
@@ -3342,6 +3424,7 @@ export async function sendMessage() {
|
|
|
3342
3424
|
}
|
|
3343
3425
|
if (!hasDraft) return;
|
|
3344
3426
|
closeSlashAutocomplete();
|
|
3427
|
+
closeMentionAutocomplete();
|
|
3345
3428
|
|
|
3346
3429
|
// Warn-once typo guard: a message that looks like a command but matches none
|
|
3347
3430
|
// would otherwise burn an agent turn as plain text.
|
|
@@ -3589,6 +3672,12 @@ export async function sendMessage() {
|
|
|
3589
3672
|
};
|
|
3590
3673
|
window.syncAutoModelAvailability?.();
|
|
3591
3674
|
}
|
|
3675
|
+
// Adopting the conversation can grow the composer (session-lock note,
|
|
3676
|
+
// warning banner), which shrinks the transcript under a pane that was
|
|
3677
|
+
// pinned to the newest message. Re-pin now rather than after the draft
|
|
3678
|
+
// save's round trip below, or the new bubble sits under the composer
|
|
3679
|
+
// for as long as that request takes.
|
|
3680
|
+
if (viewingSendConversation()) scrollBottom();
|
|
3592
3681
|
const persistedConversationId = String(r.conversationId || targetConversationId || '').trim();
|
|
3593
3682
|
if (persistedConversationId) {
|
|
3594
3683
|
restorableDraftByConversation.delete(persistedConversationId);
|
|
@@ -3661,12 +3750,16 @@ export async function sendMessage() {
|
|
|
3661
3750
|
// menu in sync with the caret. Cheap no-op for non-slash text.
|
|
3662
3751
|
export function updateComposerSlashMenu(input) {
|
|
3663
3752
|
updateSlashAutocomplete(input, { conversationId: currentConvId });
|
|
3753
|
+
// Second, so it sees the slash menu's verdict: the @relay popup never opens
|
|
3754
|
+
// over an open slash menu, and at most one of them ever handles keys.
|
|
3755
|
+
updateMentionAutocomplete(input);
|
|
3664
3756
|
}
|
|
3665
3757
|
|
|
3666
3758
|
export function handleKey(e) {
|
|
3667
3759
|
// The open menu owns navigation keys; Ctrl/Cmd+Enter still falls through to
|
|
3668
|
-
// send because
|
|
3760
|
+
// send because neither menu consumes it.
|
|
3669
3761
|
if (handleSlashAutocompleteKey(e, e.target)) return;
|
|
3762
|
+
if (handleMentionAutocompleteKey(e, e.target)) return;
|
|
3670
3763
|
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
|
3671
3764
|
e.preventDefault();
|
|
3672
3765
|
sendMessage();
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
// Pending host suspend / relay restart, as every device sees it: an app-wide
|
|
2
|
+
// banner with a countdown and Cancel, plus the chat-actions menu labels.
|
|
3
|
+
// State arrives over the socket (`host_suspend_state`, `relay_shutdown_state`)
|
|
4
|
+
// and is also re-read from GET /api/status polls, so a client that missed an
|
|
5
|
+
// event still catches up. The banner ignores the per-device "Show Suspend
|
|
6
|
+
// host action" setting on purpose: a device with the action hidden must still
|
|
7
|
+
// be able to see and cancel a queued suspend.
|
|
8
|
+
|
|
9
|
+
import { escHtml, showTransientRelayNotice } from './store.js';
|
|
10
|
+
import { cancelHostSuspend, cancelRelayRestart } from './api-client.js';
|
|
11
|
+
|
|
12
|
+
const BANNER_ID = 'pending-action-banner';
|
|
13
|
+
const SUSPEND_MENU_ID = 'chat-menu-suspend-host';
|
|
14
|
+
const RESTART_MENU_ID = 'chat-menu-restart-relay';
|
|
15
|
+
const SUSPEND_MENU_LABEL = '💤 Suspend host';
|
|
16
|
+
const SUSPEND_MENU_PENDING_LABEL = '💤 Suspend pending…';
|
|
17
|
+
const RESTART_MENU_LABEL = '🌄 Restart web relay';
|
|
18
|
+
const RESTART_MENU_PENDING_LABEL = '🌄 Restart pending…';
|
|
19
|
+
|
|
20
|
+
let hostSuspendState = null;
|
|
21
|
+
let relayShutdownState = null;
|
|
22
|
+
let countdownTimer = null;
|
|
23
|
+
let cancelInFlight = false;
|
|
24
|
+
|
|
25
|
+
export function getHostSuspendState() {
|
|
26
|
+
return hostSuspendState;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function getRelayShutdownState() {
|
|
30
|
+
return relayShutdownState;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function isHostSuspendPending(state = hostSuspendState) {
|
|
34
|
+
const status = String(state?.status || '').trim();
|
|
35
|
+
return status === 'queued' || status === 'countdown' || status === 'suspending';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function isRelayShutdownPending(state = relayShutdownState) {
|
|
39
|
+
const status = String(state?.status || '').trim();
|
|
40
|
+
return status === 'queued' || status === 'shutting_down';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** "0:27" style remaining time from a fireAt ISO string; null when not counting. */
|
|
44
|
+
export function formatCountdown(fireAt, nowMs = Date.now()) {
|
|
45
|
+
const target = Date.parse(String(fireAt || ''));
|
|
46
|
+
if (!Number.isFinite(target)) return null;
|
|
47
|
+
const remaining = Math.max(0, Math.ceil((target - nowMs) / 1000));
|
|
48
|
+
const minutes = Math.floor(remaining / 60);
|
|
49
|
+
const seconds = remaining % 60;
|
|
50
|
+
return `${minutes}:${String(seconds).padStart(2, '0')}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** One human line per blocker, e.g. "sidebar polish — 2 background agents: …". */
|
|
54
|
+
export function describeHostSuspendBlocker(blocker) {
|
|
55
|
+
if (!blocker || typeof blocker !== 'object') return '';
|
|
56
|
+
const title = String(blocker.title || '').trim();
|
|
57
|
+
const detail = String(blocker.detail || '').trim();
|
|
58
|
+
switch (String(blocker.kind || '')) {
|
|
59
|
+
case 'turn':
|
|
60
|
+
return `${title || 'a session'} — ${detail || 'turn running'}`;
|
|
61
|
+
case 'background':
|
|
62
|
+
return `${title || 'a session'} — ${detail || 'background agents running'}`;
|
|
63
|
+
case 'ci':
|
|
64
|
+
return `CI ${title ? `in ${title}` : ''} — ${detail || 'runs open'}`.replace(/\s+—/, ' —');
|
|
65
|
+
case 'ci-unknown':
|
|
66
|
+
return detail || `CI state of ${title || 'a repository'} unknown`;
|
|
67
|
+
case 'ci-checking':
|
|
68
|
+
return detail || 'Checking CI state';
|
|
69
|
+
case 'error':
|
|
70
|
+
return detail || 'Activity check failed';
|
|
71
|
+
default:
|
|
72
|
+
return [title, detail].filter(Boolean).join(' — ');
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Short banner summary: first two blockers, "+N more". */
|
|
77
|
+
export function summarizeHostSuspendBlockers(blockers) {
|
|
78
|
+
const list = (Array.isArray(blockers) ? blockers : []).map(describeHostSuspendBlocker).filter(Boolean);
|
|
79
|
+
if (!list.length) return '';
|
|
80
|
+
const shown = list.slice(0, 2);
|
|
81
|
+
const rest = list.length - shown.length;
|
|
82
|
+
return shown.join('; ') + (rest > 0 ? `; +${rest} more` : '');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function hostSuspendBannerText(state, nowMs = Date.now()) {
|
|
86
|
+
if (!isHostSuspendPending(state)) return '';
|
|
87
|
+
const status = String(state.status || '');
|
|
88
|
+
if (status === 'suspending') return '💤 Suspending host now…';
|
|
89
|
+
if (status === 'countdown') {
|
|
90
|
+
const countdown = formatCountdown(state.fireAt, nowMs);
|
|
91
|
+
return countdown ? `💤 Everything is idle — suspending host in ${countdown}` : '💤 Everything is idle — suspending host shortly';
|
|
92
|
+
}
|
|
93
|
+
const summary = summarizeHostSuspendBlockers(state.blockers);
|
|
94
|
+
return summary
|
|
95
|
+
? `💤 Suspend queued — waiting for: ${summary}`
|
|
96
|
+
: '💤 Suspend queued — waiting for running agents to finish';
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function relayShutdownBannerText(state) {
|
|
100
|
+
if (!isRelayShutdownPending(state)) return '';
|
|
101
|
+
const action = state.action === 'restart' || state.restart ? 'restart' : 'shutdown';
|
|
102
|
+
const icon = action === 'restart' ? '🌄' : '⏻';
|
|
103
|
+
if (String(state.status || '') === 'shutting_down') {
|
|
104
|
+
return `${icon} Relay ${action === 'restart' ? 'restarting' : 'shutting down'} now…`;
|
|
105
|
+
}
|
|
106
|
+
const queue = state.queue || {};
|
|
107
|
+
const turns = Number(queue.pendingCount || 0) + Number(queue.processingCount || 0) + Number(queue.parkedCount || 0);
|
|
108
|
+
const waiting = turns > 0 ? `waiting for ${turns} turn${turns === 1 ? '' : 's'} to finish` : 'queue is idle, going down now';
|
|
109
|
+
return `${icon} Relay ${action} queued — ${waiting}`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function ensureBanner() {
|
|
113
|
+
let banner = document.getElementById(BANNER_ID);
|
|
114
|
+
if (banner) return banner;
|
|
115
|
+
banner = document.createElement('div');
|
|
116
|
+
banner.id = BANNER_ID;
|
|
117
|
+
banner.setAttribute('role', 'status');
|
|
118
|
+
banner.setAttribute('aria-live', 'polite');
|
|
119
|
+
document.body?.appendChild(banner);
|
|
120
|
+
return banner;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function bannerRow({ text, cancelLabel, onCancel, key }) {
|
|
124
|
+
const row = document.createElement('div');
|
|
125
|
+
row.className = 'pending-action-row';
|
|
126
|
+
row.dataset.pending = key;
|
|
127
|
+
const label = document.createElement('span');
|
|
128
|
+
label.className = 'pending-action-text';
|
|
129
|
+
label.textContent = text;
|
|
130
|
+
row.appendChild(label);
|
|
131
|
+
if (cancelLabel && typeof onCancel === 'function') {
|
|
132
|
+
const btn = document.createElement('button');
|
|
133
|
+
btn.type = 'button';
|
|
134
|
+
btn.className = 'relay-toast-action pending-action-cancel';
|
|
135
|
+
btn.textContent = cancelLabel;
|
|
136
|
+
btn.disabled = cancelInFlight;
|
|
137
|
+
btn.addEventListener('click', (event) => {
|
|
138
|
+
event.preventDefault();
|
|
139
|
+
event.stopPropagation();
|
|
140
|
+
void onCancel();
|
|
141
|
+
});
|
|
142
|
+
row.appendChild(btn);
|
|
143
|
+
}
|
|
144
|
+
return row;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function runCancelHostSuspend() {
|
|
148
|
+
if (cancelInFlight) return;
|
|
149
|
+
cancelInFlight = true;
|
|
150
|
+
renderPendingActionBanner();
|
|
151
|
+
try {
|
|
152
|
+
const result = await cancelHostSuspend();
|
|
153
|
+
if (!result?.ok) {
|
|
154
|
+
showTransientRelayNotice('Failed to cancel the queued suspend.', 5000);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
if (result.state) applyHostSuspendState(result.state);
|
|
158
|
+
showTransientRelayNotice(result.cancelled ? 'Queued host suspend cancelled.' : 'No host suspend was pending.', 4000);
|
|
159
|
+
} finally {
|
|
160
|
+
cancelInFlight = false;
|
|
161
|
+
renderPendingActionBanner();
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function runCancelRelayShutdown() {
|
|
166
|
+
if (cancelInFlight) return;
|
|
167
|
+
cancelInFlight = true;
|
|
168
|
+
renderPendingActionBanner();
|
|
169
|
+
try {
|
|
170
|
+
const result = await cancelRelayRestart();
|
|
171
|
+
if (!result?.ok) {
|
|
172
|
+
showTransientRelayNotice('Failed to cancel the queued relay restart.', 5000);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
applyRelayShutdownState(result);
|
|
176
|
+
showTransientRelayNotice(result.cancelled ? 'Queued relay restart cancelled.' : 'No relay restart was pending.', 4000);
|
|
177
|
+
} finally {
|
|
178
|
+
cancelInFlight = false;
|
|
179
|
+
renderPendingActionBanner();
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function syncCountdownTimer() {
|
|
184
|
+
const needsTicker = isHostSuspendPending() && String(hostSuspendState?.status || '') === 'countdown' && hostSuspendState?.fireAt;
|
|
185
|
+
if (needsTicker && !countdownTimer) {
|
|
186
|
+
countdownTimer = setInterval(() => renderPendingActionBanner(), 1000);
|
|
187
|
+
} else if (!needsTicker && countdownTimer) {
|
|
188
|
+
clearInterval(countdownTimer);
|
|
189
|
+
countdownTimer = null;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function renderPendingActionBanner() {
|
|
194
|
+
if (typeof document === 'undefined') return;
|
|
195
|
+
const rows = [];
|
|
196
|
+
if (isHostSuspendPending()) {
|
|
197
|
+
rows.push(bannerRow({
|
|
198
|
+
key: 'host-suspend',
|
|
199
|
+
text: hostSuspendBannerText(hostSuspendState),
|
|
200
|
+
cancelLabel: String(hostSuspendState?.status || '') === 'suspending' ? '' : 'Cancel',
|
|
201
|
+
onCancel: runCancelHostSuspend,
|
|
202
|
+
}));
|
|
203
|
+
}
|
|
204
|
+
if (isRelayShutdownPending()) {
|
|
205
|
+
const restarting = String(relayShutdownState?.status || '') === 'shutting_down';
|
|
206
|
+
rows.push(bannerRow({
|
|
207
|
+
key: 'relay-shutdown',
|
|
208
|
+
text: relayShutdownBannerText(relayShutdownState),
|
|
209
|
+
cancelLabel: restarting ? '' : 'Cancel',
|
|
210
|
+
onCancel: runCancelRelayShutdown,
|
|
211
|
+
}));
|
|
212
|
+
}
|
|
213
|
+
const banner = document.getElementById(BANNER_ID) || (rows.length ? ensureBanner() : null);
|
|
214
|
+
if (!banner) return;
|
|
215
|
+
banner.replaceChildren(...rows);
|
|
216
|
+
banner.classList.toggle('visible', rows.length > 0);
|
|
217
|
+
banner.hidden = rows.length === 0;
|
|
218
|
+
syncMenuLabels();
|
|
219
|
+
syncCountdownTimer();
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function syncMenuLabels() {
|
|
223
|
+
const suspendBtn = document.getElementById(SUSPEND_MENU_ID);
|
|
224
|
+
if (suspendBtn) suspendBtn.textContent = isHostSuspendPending() ? SUSPEND_MENU_PENDING_LABEL : SUSPEND_MENU_LABEL;
|
|
225
|
+
const restartBtn = document.getElementById(RESTART_MENU_ID);
|
|
226
|
+
if (restartBtn) restartBtn.textContent = isRelayShutdownPending() ? RESTART_MENU_PENDING_LABEL : RESTART_MENU_LABEL;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function applyHostSuspendState(state) {
|
|
230
|
+
const previous = hostSuspendState;
|
|
231
|
+
hostSuspendState = state && typeof state === 'object' ? state : null;
|
|
232
|
+
if (isHostSuspendPending(previous) && !isHostSuspendPending(hostSuspendState) && hostSuspendState?.lastError) {
|
|
233
|
+
showTransientRelayNotice(`Host suspend failed: ${hostSuspendState.lastError}`, 8000);
|
|
234
|
+
}
|
|
235
|
+
renderPendingActionBanner();
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export function applyRelayShutdownState(state) {
|
|
239
|
+
relayShutdownState = state && typeof state === 'object' ? state : null;
|
|
240
|
+
renderPendingActionBanner();
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** GET /api/status carries both states; the poll keeps late clients honest. */
|
|
244
|
+
export function applyPendingActionsFromStatus(status) {
|
|
245
|
+
if (!status || typeof status !== 'object') return;
|
|
246
|
+
if ('hostSuspend' in status) hostSuspendState = status.hostSuspend || null;
|
|
247
|
+
if ('relayShutdown' in status) {
|
|
248
|
+
const next = status.relayShutdown ? { ...status.relayShutdown } : null;
|
|
249
|
+
if (next && !next.queue) {
|
|
250
|
+
next.queue = {
|
|
251
|
+
pendingCount: Number(status.pendingCount || 0),
|
|
252
|
+
processingCount: Number(status.processingCount || 0),
|
|
253
|
+
parkedCount: Number(status.parkedCount || 0),
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
relayShutdownState = next;
|
|
257
|
+
}
|
|
258
|
+
renderPendingActionBanner();
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export function initHostSuspendUi() {
|
|
262
|
+
renderPendingActionBanner();
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export function __resetHostSuspendUiForTests() {
|
|
266
|
+
hostSuspendState = null;
|
|
267
|
+
relayShutdownState = null;
|
|
268
|
+
cancelInFlight = false;
|
|
269
|
+
if (countdownTimer) { clearInterval(countdownTimer); countdownTimer = null; }
|
|
270
|
+
document.getElementById(BANNER_ID)?.remove();
|
|
271
|
+
syncMenuLabels();
|
|
272
|
+
}
|
|
@@ -58,6 +58,7 @@ import {
|
|
|
58
58
|
conversationProviderIndicatorLabel,
|
|
59
59
|
} from './conversation-provider-indicator.mjs';
|
|
60
60
|
import { leaveStatusView } from './status-view.mjs';
|
|
61
|
+
import { renderConversationOriginMarkerHtml } from './remote-relay-origin-view.mjs';
|
|
61
62
|
import {
|
|
62
63
|
normalizeConversationFilter,
|
|
63
64
|
filterConversations,
|
|
@@ -349,10 +350,12 @@ export function renderConvList() {
|
|
|
349
350
|
const providerIndicatorHtml = providerIndicatorLabel
|
|
350
351
|
? `<span class="conv-provider-indicator"${providerIndicatorKey ? ` data-provider="${providerIndicatorKey}"` : ''}>${providerIndicatorLabel}</span>`
|
|
351
352
|
: '';
|
|
353
|
+
// "↗ win-test": another relay's agent created this conversation.
|
|
354
|
+
const originMarkerHtml = renderConversationOriginMarkerHtml(c);
|
|
352
355
|
return `
|
|
353
356
|
<div class="conv-item worker-ui-${view.visualState}${c.id === currentConvId ? ' active' : ''}" onclick="openConversation('${c.id}')">
|
|
354
357
|
<div class="conv-title">${escHtml(c.title)}${processingDots ? `<span class="conv-processing-dots">${escHtml(` ${processingDots}`)}</span>` : ''}${c.archived ? ' <span style="font-size:0.68rem;color:var(--muted)">(archived)</span>' : ''}${pendingByConversation[c.id] ? ` <span class="conv-open-questions">${pendingByConversation[c.id]} open</span>` : ''}</div>
|
|
355
|
-
<div class="conv-meta"><span class="conv-meta-primary">${fmtDate(c.updatedAt)} · ${c.messageCount} msg${c.messageCount !== 1 ? 's' : ''}</span>${providerIndicatorHtml}</div>
|
|
358
|
+
<div class="conv-meta"><span class="conv-meta-primary">${fmtDate(c.updatedAt)} · ${c.messageCount} msg${c.messageCount !== 1 ? 's' : ''}</span>${originMarkerHtml}${providerIndicatorHtml}</div>
|
|
356
359
|
<button class="conv-delete" onclick="deleteConv(event,'${c.id}')" title="Delete">🗑</button>
|
|
357
360
|
</div>`;
|
|
358
361
|
}).join('')}${footerHtml}`;
|