@oar-sh/oar 0.9.3 → 0.9.4
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 +67 -0
- package/README.md +62 -13
- package/package.json +5 -2
- package/server/README.md +43 -3
- package/server/claude-worker/claude-remote-relay-tool.mjs +99 -0
- package/server/claude-worker/claude-sdk-adapter.mjs +26 -4
- package/server/claude-worker/claude-session-process.mjs +92 -25
- 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/cursor-worker/cursor-remote-relay-tool.mjs +65 -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-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 +98 -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/thinking-follow.js +260 -0
- package/server/public/app/tool-activity-emoji.mjs +4 -0
- package/server/public/index.html +257 -8
- 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 +90 -16
- package/server/routes/remote-relay-routes.mjs +133 -0
- package/server/routes/sessions-routes.mjs +80 -29
- 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 +341 -0
- package/server/services/remote-relay-client.mjs +332 -0
- package/server/services/remote-relay-dispatcher.mjs +1500 -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 +131 -12
- package/shared/remote-relay-contract.mjs +539 -0
- package/shared/remote-relay-mentions.mjs +129 -0
- package/shared/remote-relay-tool-core.mjs +314 -0
- package/shared/worker-runtime/api-client.mjs +112 -22
|
@@ -5,16 +5,26 @@ import {
|
|
|
5
5
|
openSummaryModal,
|
|
6
6
|
closeSummaryModal,
|
|
7
7
|
setSummaryModalLoading,
|
|
8
|
+
renderSummaryModalContent,
|
|
9
|
+
summaryModalState,
|
|
8
10
|
showTransientRelayNotice,
|
|
9
11
|
} from './store.js';
|
|
10
12
|
import {
|
|
11
13
|
killSessionWorker,
|
|
12
14
|
requestRelayRestart,
|
|
13
15
|
requestHostSuspend,
|
|
16
|
+
getHostSuspendState,
|
|
17
|
+
cancelHostSuspend,
|
|
14
18
|
requestQueueEmpty,
|
|
15
19
|
refreshWorkspaceRootHints,
|
|
16
20
|
} from './api-client.js';
|
|
17
21
|
import { isSuspendHostActionVisible } from './settings-modal.js';
|
|
22
|
+
import {
|
|
23
|
+
applyHostSuspendState,
|
|
24
|
+
describeHostSuspendBlocker,
|
|
25
|
+
formatCountdown,
|
|
26
|
+
isHostSuspendPending,
|
|
27
|
+
} from './host-suspend-ui.js';
|
|
18
28
|
|
|
19
29
|
let killSessionInFlight = false;
|
|
20
30
|
let restartRelayInFlight = false;
|
|
@@ -129,22 +139,111 @@ export function openEmptyQueueConfirmation() {
|
|
|
129
139
|
});
|
|
130
140
|
}
|
|
131
141
|
|
|
142
|
+
const SUSPEND_MODAL_KIND = 'suspend-host';
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Body of the Suspend host modal for one activity snapshot. Pure so the three
|
|
146
|
+
* shapes (idle, busy, already pending) are unit-testable without a DOM.
|
|
147
|
+
*/
|
|
148
|
+
export function renderSuspendHostModalBody({ state = null, blockers = [], supported = true } = {}) {
|
|
149
|
+
const list = Array.isArray(blockers) ? blockers : [];
|
|
150
|
+
const items = list.map((b) => `<li>${escHtml(describeHostSuspendBlocker(b))}</li>`).join('');
|
|
151
|
+
if (!supported) {
|
|
152
|
+
return `
|
|
153
|
+
<p>Host suspend is only available when the relay runs on Windows.</p>
|
|
154
|
+
<div class="summary-modal-actions">
|
|
155
|
+
<button class="chat-title-action-btn" type="button" onclick="closeSummaryModal()">Close</button>
|
|
156
|
+
</div>`;
|
|
157
|
+
}
|
|
158
|
+
if (isHostSuspendPending(state)) {
|
|
159
|
+
const status = String(state?.status || '');
|
|
160
|
+
const countdown = status === 'countdown' ? formatCountdown(state.fireAt) : null;
|
|
161
|
+
const headline = status === 'suspending'
|
|
162
|
+
? '<p>The host is suspending now.</p>'
|
|
163
|
+
: status === 'countdown'
|
|
164
|
+
? `<p>Everything is idle. The host suspends in <strong>${escHtml(countdown || 'a moment')}</strong> unless you cancel.</p>`
|
|
165
|
+
: '<p>A suspend is already queued. The PC will not go to sleep until these finish, then after 2 minutes of quiet:</p>';
|
|
166
|
+
return `
|
|
167
|
+
${headline}
|
|
168
|
+
${items ? `<ul class="suspend-host-blockers">${items}</ul>` : ''}
|
|
169
|
+
<div class="summary-modal-actions">
|
|
170
|
+
<button class="chat-title-action-btn danger-btn" type="button" onclick="cancelQueuedHostSuspend()"${status === 'suspending' ? ' disabled data-keep-disabled="1"' : ''}>Cancel queued suspend</button>
|
|
171
|
+
<button class="chat-title-action-btn" type="button" onclick="closeSummaryModal()">Close</button>
|
|
172
|
+
</div>`;
|
|
173
|
+
}
|
|
174
|
+
if (!list.length) {
|
|
175
|
+
return `
|
|
176
|
+
<p>Put this PC to sleep?</p>
|
|
177
|
+
<p>Nothing is running. The host suspends <strong>30 seconds</strong> after you confirm; a banner with a countdown lets you cancel until then.</p>
|
|
178
|
+
<div class="summary-modal-actions">
|
|
179
|
+
<button class="chat-title-action-btn" type="button" onclick="confirmSuspendHost()">💤 Suspend host</button>
|
|
180
|
+
<button class="chat-title-action-btn" type="button" onclick="closeSummaryModal()">Cancel</button>
|
|
181
|
+
</div>`;
|
|
182
|
+
}
|
|
183
|
+
return `
|
|
184
|
+
<p><strong>Agents are still active.</strong> The PC will not suspend until these finish, then after 2 minutes of quiet:</p>
|
|
185
|
+
<ul class="suspend-host-blockers">${items}</ul>
|
|
186
|
+
<p>You can cancel the queued suspend from the banner or this menu at any time.</p>
|
|
187
|
+
<div class="summary-modal-actions">
|
|
188
|
+
<button class="chat-title-action-btn" type="button" onclick="confirmSuspendHost()">💤 Suspend when idle</button>
|
|
189
|
+
<button class="chat-title-action-btn" type="button" onclick="closeSummaryModal()">Cancel</button>
|
|
190
|
+
</div>`;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function loadSuspendHostModal() {
|
|
194
|
+
const result = await getHostSuspendState();
|
|
195
|
+
if (summaryModalState.kind !== SUSPEND_MODAL_KIND) return;
|
|
196
|
+
if (!result?.ok) {
|
|
197
|
+
renderSummaryModalContent({
|
|
198
|
+
title: 'Suspend host',
|
|
199
|
+
subtitle: 'Suspend-to-RAM once agents are done',
|
|
200
|
+
kind: SUSPEND_MODAL_KIND,
|
|
201
|
+
refresh: loadSuspendHostModal,
|
|
202
|
+
bodyHtml: `
|
|
203
|
+
<div class="summary-error">Could not read what is running right now.</div>
|
|
204
|
+
<div class="summary-modal-actions">
|
|
205
|
+
<button class="chat-title-action-btn" type="button" onclick="closeSummaryModal()">Close</button>
|
|
206
|
+
</div>`,
|
|
207
|
+
});
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
if (result.state) applyHostSuspendState(result.state);
|
|
211
|
+
renderSummaryModalContent({
|
|
212
|
+
title: 'Suspend host',
|
|
213
|
+
subtitle: isHostSuspendPending(result.state) ? 'Suspend queued' : 'Suspend-to-RAM once agents are done',
|
|
214
|
+
kind: SUSPEND_MODAL_KIND,
|
|
215
|
+
refresh: loadSuspendHostModal,
|
|
216
|
+
bodyHtml: renderSuspendHostModalBody({
|
|
217
|
+
state: result.state,
|
|
218
|
+
blockers: result.blockers,
|
|
219
|
+
supported: result.supported !== false,
|
|
220
|
+
}),
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
132
224
|
export function openSuspendHostConfirmation() {
|
|
133
|
-
if (!isSuspendHostActionVisible()) return;
|
|
225
|
+
if (!isSuspendHostActionVisible() && !isHostSuspendPending()) return;
|
|
134
226
|
menuDeps.lockChatActionsMenuShield(350);
|
|
135
227
|
menuDeps.closeChatActionsMenu();
|
|
136
228
|
openSummaryModal({
|
|
137
229
|
title: 'Suspend host',
|
|
138
|
-
subtitle: '
|
|
139
|
-
kind:
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
230
|
+
subtitle: 'Suspend-to-RAM once agents are done',
|
|
231
|
+
kind: SUSPEND_MODAL_KIND,
|
|
232
|
+
refresh: loadSuspendHostModal,
|
|
233
|
+
bodyHtml: '<div class="summary-loading">Checking what is still running…</div>',
|
|
234
|
+
});
|
|
235
|
+
setSummaryModalLoading(true);
|
|
236
|
+
loadSuspendHostModal().catch(() => {
|
|
237
|
+
if (summaryModalState.kind !== SUSPEND_MODAL_KIND) return;
|
|
238
|
+
renderSummaryModalContent({
|
|
239
|
+
title: 'Suspend host',
|
|
240
|
+
kind: SUSPEND_MODAL_KIND,
|
|
241
|
+
bodyHtml: `
|
|
242
|
+
<div class="summary-error">Could not read what is running right now.</div>
|
|
243
|
+
<div class="summary-modal-actions">
|
|
244
|
+
<button class="chat-title-action-btn" type="button" onclick="closeSummaryModal()">Close</button>
|
|
245
|
+
</div>`,
|
|
246
|
+
});
|
|
148
247
|
});
|
|
149
248
|
window.setTimeout(() => {
|
|
150
249
|
const modal = document.getElementById('summary-modal');
|
|
@@ -152,7 +251,7 @@ export function openSuspendHostConfirmation() {
|
|
|
152
251
|
const ariaVisible = String(modal?.getAttribute('aria-hidden') || 'true') === 'false';
|
|
153
252
|
const displayVisible = modal ? window.getComputedStyle(modal).display !== 'none' : false;
|
|
154
253
|
if (classVisible && ariaVisible && displayVisible) return;
|
|
155
|
-
const confirmed = window.confirm('Put this PC to sleep
|
|
254
|
+
const confirmed = window.confirm('Put this PC to sleep once all agents are done?\n\nThe suspend is queued and fires after everything has been idle.');
|
|
156
255
|
if (!confirmed) return;
|
|
157
256
|
confirmSuspendHost().catch(() => {});
|
|
158
257
|
}, 90);
|
|
@@ -170,9 +269,37 @@ export async function confirmSuspendHost() {
|
|
|
170
269
|
});
|
|
171
270
|
closeSummaryModal();
|
|
172
271
|
if (!result?.ok) {
|
|
173
|
-
alert('Failed to
|
|
272
|
+
alert('Failed to queue host suspend');
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
if (result.state) applyHostSuspendState(result.state);
|
|
276
|
+
const status = String(result.state?.status || '');
|
|
277
|
+
if (result.alreadyPending) {
|
|
278
|
+
showTransientRelayNotice('A host suspend is already queued.', 5000);
|
|
279
|
+
} else if (status === 'countdown') {
|
|
280
|
+
showTransientRelayNotice('Host suspend queued: everything is idle, sleeping in 30 seconds. Cancel from the banner.', 7000);
|
|
281
|
+
} else {
|
|
282
|
+
showTransientRelayNotice('Host suspend queued: the PC sleeps once all agents are done.', 7000);
|
|
283
|
+
}
|
|
284
|
+
} finally {
|
|
285
|
+
suspendHostInFlight = false;
|
|
286
|
+
setSummaryModalLoading(false);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export async function cancelQueuedHostSuspend() {
|
|
291
|
+
if (suspendHostInFlight) return;
|
|
292
|
+
suspendHostInFlight = true;
|
|
293
|
+
setSummaryModalLoading(true);
|
|
294
|
+
try {
|
|
295
|
+
const result = await cancelHostSuspend();
|
|
296
|
+
closeSummaryModal();
|
|
297
|
+
if (!result?.ok) {
|
|
298
|
+
alert('Failed to cancel the queued host suspend');
|
|
174
299
|
return;
|
|
175
300
|
}
|
|
301
|
+
if (result.state) applyHostSuspendState(result.state);
|
|
302
|
+
showTransientRelayNotice(result.cancelled ? 'Queued host suspend cancelled.' : 'No host suspend was pending.', 4000);
|
|
176
303
|
} finally {
|
|
177
304
|
suspendHostInFlight = false;
|
|
178
305
|
setSummaryModalLoading(false);
|
|
@@ -263,5 +390,6 @@ export function initActionConfirmations({
|
|
|
263
390
|
window.confirmKillCurrentSession = confirmKillCurrentSession;
|
|
264
391
|
window.confirmRestartWebRelay = confirmRestartWebRelay;
|
|
265
392
|
window.confirmSuspendHost = confirmSuspendHost;
|
|
393
|
+
window.cancelQueuedHostSuspend = cancelQueuedHostSuspend;
|
|
266
394
|
window.confirmEmptyQueue = confirmEmptyQueue;
|
|
267
395
|
}
|
|
@@ -457,6 +457,91 @@ export async function updatePwaAppNameSetting(appName) {
|
|
|
457
457
|
});
|
|
458
458
|
}
|
|
459
459
|
|
|
460
|
+
// ─── Remote relays (Settings → Relays) ───────────────────────────────────────
|
|
461
|
+
// Adding a relay has several distinct refusals the form has to tell apart
|
|
462
|
+
// (needs a token, this is this relay, bad address), so these calls hand back
|
|
463
|
+
// the relay's JSON body on failure too — `{ ok:false, status, code, error }` —
|
|
464
|
+
// instead of apiFetch's bare null. They never throw; null only when network
|
|
465
|
+
// requests are paused.
|
|
466
|
+
|
|
467
|
+
async function remoteRelayRequest(path, { method = 'GET', body = undefined, timeoutMs = REQUEST_TIMEOUT_MS } = {}) {
|
|
468
|
+
if (!networkRequestsEnabled) return null;
|
|
469
|
+
try {
|
|
470
|
+
const response = await fetch(`${BASE}${path}`, {
|
|
471
|
+
signal: requestTimeoutSignal(timeoutMs),
|
|
472
|
+
method,
|
|
473
|
+
headers: {
|
|
474
|
+
'Content-Type': 'application/json',
|
|
475
|
+
...authHeaders(),
|
|
476
|
+
},
|
|
477
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
478
|
+
});
|
|
479
|
+
const payload = await response.json().catch(() => null);
|
|
480
|
+
noteFetchSuccess();
|
|
481
|
+
const result = payload && typeof payload === 'object' && !Array.isArray(payload) ? payload : {};
|
|
482
|
+
if (!response.ok) {
|
|
483
|
+
return {
|
|
484
|
+
...result,
|
|
485
|
+
ok: false,
|
|
486
|
+
status: response.status,
|
|
487
|
+
error: String(result.error || `Request failed (${response.status})`),
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
return { ok: true, ...result, status: response.status };
|
|
491
|
+
} catch (error) {
|
|
492
|
+
noteFetchFailure(path, error);
|
|
493
|
+
return { ok: false, status: 0, code: 'NETWORK', error: toErrorMessage(error) };
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function remoteRelayPath(id, suffix = '') {
|
|
498
|
+
return `/api/remote-relays/${encodeURIComponent(String(id || '').trim())}${suffix}`;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/** `{ relays, self }`; never carries tokens. Null on any failure. */
|
|
502
|
+
export async function loadRemoteRelays() {
|
|
503
|
+
return apiFetch('/api/remote-relays');
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Probes and saves a remote relay, and (pairBack) asks it to add this relay.
|
|
508
|
+
* The probe and the pair-back are two remote round trips of up to 15 s each,
|
|
509
|
+
* hence the long timeout.
|
|
510
|
+
*/
|
|
511
|
+
export async function addRemoteRelay({ url, token = '', pairBack = true, selfUrl = '' } = {}) {
|
|
512
|
+
const body = { url: String(url || '').trim(), pairBack: pairBack !== false };
|
|
513
|
+
const trimmedToken = String(token || '').trim();
|
|
514
|
+
if (trimmedToken) body.token = trimmedToken;
|
|
515
|
+
const trimmedSelfUrl = String(selfUrl || '').trim();
|
|
516
|
+
if (trimmedSelfUrl) body.selfUrl = trimmedSelfUrl;
|
|
517
|
+
return remoteRelayRequest('/api/remote-relays', { method: 'POST', body, timeoutMs: LONG_REQUEST_TIMEOUT_MS });
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/** `patch`: any of `{ permission, url, token, tokenMode }`. */
|
|
521
|
+
export async function updateRemoteRelay(id, patch = {}) {
|
|
522
|
+
return remoteRelayRequest(remoteRelayPath(id), { method: 'PATCH', body: patch || {} });
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
export async function removeRemoteRelay(id) {
|
|
526
|
+
return remoteRelayRequest(remoteRelayPath(id), { method: 'DELETE' });
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
export async function checkRemoteRelay(id) {
|
|
530
|
+
return remoteRelayRequest(remoteRelayPath(id, '/check'), { method: 'POST', body: {}, timeoutMs: LONG_REQUEST_TIMEOUT_MS });
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/** `{ publicUrl, inboundEnabled }` for this relay. */
|
|
534
|
+
export async function loadRemoteRelaySettings() {
|
|
535
|
+
return apiFetch('/api/settings/remote-relays');
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
export async function updateRemoteRelaySettings(patch = {}) {
|
|
539
|
+
const body = {};
|
|
540
|
+
if (typeof patch.publicUrl === 'string') body.publicUrl = patch.publicUrl.trim();
|
|
541
|
+
if (typeof patch.inboundEnabled === 'boolean') body.inboundEnabled = patch.inboundEnabled;
|
|
542
|
+
return remoteRelayRequest('/api/settings/remote-relays', { method: 'POST', body });
|
|
543
|
+
}
|
|
544
|
+
|
|
460
545
|
export async function loadUpdateState() {
|
|
461
546
|
return apiFetch('/api/update/state');
|
|
462
547
|
}
|
|
@@ -876,6 +961,24 @@ export async function requestHostSuspend(body = {}) {
|
|
|
876
961
|
});
|
|
877
962
|
}
|
|
878
963
|
|
|
964
|
+
export async function getHostSuspendState() {
|
|
965
|
+
return apiFetch('/api/host/suspend');
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
export async function cancelHostSuspend(body = {}) {
|
|
969
|
+
return apiFetch('/api/host/suspend/cancel', {
|
|
970
|
+
method: 'POST',
|
|
971
|
+
body: JSON.stringify({ requestedBy: 'localhost-api', ...(body || {}) }),
|
|
972
|
+
});
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
export async function cancelRelayRestart(body = {}) {
|
|
976
|
+
return apiFetch('/api/relay/shutdown/cancel', {
|
|
977
|
+
method: 'POST',
|
|
978
|
+
body: JSON.stringify({ requestedBy: 'localhost-api', ...(body || {}) }),
|
|
979
|
+
});
|
|
980
|
+
}
|
|
981
|
+
|
|
879
982
|
export async function requestQueueEmpty(body = {}) {
|
|
880
983
|
return apiFetch('/api/queue/empty', {
|
|
881
984
|
method: 'POST',
|
|
@@ -119,6 +119,14 @@ import { loadRepoBrowserTree, openRepoBrowser, closeRepoBrowser, setRepoBrowserS
|
|
|
119
119
|
import { handleAttachmentInput, retryAttachmentUpload, openAnnotateEditorForPending, openAnnotateEditorForPreview, handleComposerPaste, handleComposerDrop, refreshComposerAttachmentWarning, removeAttachment, clearAttachments, openUploadedAttachmentViewer, setFilePreviewMode, toggleFilePreviewHtml, copyFilePreviewImage, closeFilePreview, goBackFilePreview, openWorkspaceFilePreview, openWorkspaceFilePreviewFromRepo, setRepoBrowserRoot, setRepoBrowserViewMode, toggleRepoBrowserHidden, toggleRepoBrowserHeavy, refreshRepoBrowser, focusRepoTree, setRepoCurrentPath, confirmRepoBrowserCwdPick } from './attachments-view.js';
|
|
120
120
|
import { initEmojiPicker, toggleEmojiPicker } from './emoji-view.js';
|
|
121
121
|
import { initSlashAutocomplete } from './slash-autocomplete.mjs';
|
|
122
|
+
import { initMentionAutocomplete } from './mention-autocomplete.mjs';
|
|
123
|
+
import { consumeConversationDeepLink } from './conversation-deep-link.mjs';
|
|
124
|
+
import {
|
|
125
|
+
addRemoteRelayFromForm,
|
|
126
|
+
saveRemoteRelayPublicUrl,
|
|
127
|
+
showRemoteRelayTokenField,
|
|
128
|
+
toggleRemoteRelayInbound,
|
|
129
|
+
} from './remote-relays-settings.js';
|
|
122
130
|
import { dataTransferHasFiles } from './composer-paste.mjs';
|
|
123
131
|
import { isReasoningOffUnsupported, reasoningEffortOptionLabel, reasoningEffortOptionTitle } from './reasoning-effort-labels.mjs';
|
|
124
132
|
import {
|
|
@@ -301,7 +309,9 @@ import {
|
|
|
301
309
|
confirmEmptyQueue,
|
|
302
310
|
openSuspendHostConfirmation,
|
|
303
311
|
confirmSuspendHost,
|
|
312
|
+
cancelQueuedHostSuspend,
|
|
304
313
|
} from './action-confirmations.js';
|
|
314
|
+
import { applyPendingActionsFromStatus, initHostSuspendUi } from './host-suspend-ui.js';
|
|
305
315
|
import {
|
|
306
316
|
startClaudeRelogin,
|
|
307
317
|
submitClaudeLoginCodeFromInput,
|
|
@@ -698,13 +708,10 @@ function initPushNotificationClientHooks() {
|
|
|
698
708
|
});
|
|
699
709
|
}
|
|
700
710
|
|
|
711
|
+
// ?push_conv=<id> (a notification tap) and ?conv=<id> (another relay's
|
|
712
|
+
// provenance badge links here) both open that conversation once.
|
|
701
713
|
function consumePushConversationDeepLink() {
|
|
702
|
-
|
|
703
|
-
const conversationId = String(url.searchParams.get('push_conv') || '').trim();
|
|
704
|
-
if (!conversationId) return '';
|
|
705
|
-
url.searchParams.delete('push_conv');
|
|
706
|
-
history.replaceState(null, document.title, `${url.pathname}${url.search}${url.hash}`);
|
|
707
|
-
return conversationId;
|
|
714
|
+
return consumeConversationDeepLink();
|
|
708
715
|
}
|
|
709
716
|
|
|
710
717
|
function handleForegroundTransition(reason, { immediate = false } = {}) {
|
|
@@ -3242,6 +3249,7 @@ async function refreshSessionWorkerStatus() {
|
|
|
3242
3249
|
const status = await refreshWorkspaceRootHints();
|
|
3243
3250
|
if (!status) return;
|
|
3244
3251
|
syncQueueStatusMenuEntry(status);
|
|
3252
|
+
applyPendingActionsFromStatus(status);
|
|
3245
3253
|
if (setSessionWorkerStatesFromStatusPayload(status.sessionWorker)) {
|
|
3246
3254
|
renderConvList();
|
|
3247
3255
|
// A worker steering snapshot may have changed; the composer's Steer
|
|
@@ -3950,6 +3958,7 @@ async function initApp() {
|
|
|
3950
3958
|
syncQueueStatusMenuEntry();
|
|
3951
3959
|
if (!sharedMode) {
|
|
3952
3960
|
syncSuspendHostVisibility();
|
|
3961
|
+
initHostSuspendUi();
|
|
3953
3962
|
}
|
|
3954
3963
|
setupViewportTracking();
|
|
3955
3964
|
bindChatSelectionGuard();
|
|
@@ -4185,6 +4194,7 @@ async function initApp() {
|
|
|
4185
4194
|
initChatTitleCopy();
|
|
4186
4195
|
initEmojiPicker();
|
|
4187
4196
|
initSlashAutocomplete();
|
|
4197
|
+
initMentionAutocomplete();
|
|
4188
4198
|
initConversationListLazyLoading();
|
|
4189
4199
|
initConversationFilter();
|
|
4190
4200
|
initConversationHistoryLazyLoading();
|
|
@@ -4317,6 +4327,10 @@ window.togglePushOnThisDevice = togglePushOnThisDevice;
|
|
|
4317
4327
|
window.updatePushPreferencesFromControls = updatePushPreferencesFromControls;
|
|
4318
4328
|
window.openSettingsModal = openSettingsModal;
|
|
4319
4329
|
window.closeSettingsModal = closeSettingsModal;
|
|
4330
|
+
window.addRemoteRelayFromForm = addRemoteRelayFromForm;
|
|
4331
|
+
window.showRemoteRelayTokenField = showRemoteRelayTokenField;
|
|
4332
|
+
window.saveRemoteRelayPublicUrl = saveRemoteRelayPublicUrl;
|
|
4333
|
+
window.toggleRemoteRelayInbound = toggleRemoteRelayInbound;
|
|
4320
4334
|
window.doAuth = doAuth;
|
|
4321
4335
|
window.initApp = initApp;
|
|
4322
4336
|
window.connectSocket = connectSocket;
|
|
@@ -4413,6 +4427,7 @@ window.openSuspendHostConfirmation = openSuspendHostConfirmation;
|
|
|
4413
4427
|
window.confirmKillCurrentSession = confirmKillCurrentSession;
|
|
4414
4428
|
window.confirmRestartWebRelay = confirmRestartWebRelay;
|
|
4415
4429
|
window.confirmSuspendHost = confirmSuspendHost;
|
|
4430
|
+
window.cancelQueuedHostSuspend = cancelQueuedHostSuspend;
|
|
4416
4431
|
window.confirmEmptyQueue = confirmEmptyQueue;
|
|
4417
4432
|
window.openMessageSearchModal = openMessageSearchModal;
|
|
4418
4433
|
window.closeMessageSearchModal = closeMessageSearchModal;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Links that open one conversation when the app loads:
|
|
2
|
+
// ?push_conv=<id> a notification tap (sw.js opens a fresh window with it)
|
|
3
|
+
// ?conv=<id> a link from another relay's provenance badge
|
|
4
|
+
// (remoteConversationUrl in shared/remote-relay-contract.mjs)
|
|
5
|
+
// Both are consumed once and stripped from the address bar, so a reload does
|
|
6
|
+
// not jump back to that conversation.
|
|
7
|
+
|
|
8
|
+
export const CONVERSATION_DEEP_LINK_PARAMS = Object.freeze(['push_conv', 'conv']);
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Reads the conversation a URL asks for. `cleanedPath` is the URL to put back
|
|
12
|
+
* in the address bar (path + remaining query + hash), or null when the URL
|
|
13
|
+
* carried none of the parameters and needs no rewrite.
|
|
14
|
+
*/
|
|
15
|
+
export function readConversationDeepLink(href) {
|
|
16
|
+
let url;
|
|
17
|
+
try {
|
|
18
|
+
url = new URL(String(href || ''));
|
|
19
|
+
} catch {
|
|
20
|
+
return { conversationId: '', cleanedPath: null };
|
|
21
|
+
}
|
|
22
|
+
let conversationId = '';
|
|
23
|
+
let found = false;
|
|
24
|
+
for (const param of CONVERSATION_DEEP_LINK_PARAMS) {
|
|
25
|
+
if (!url.searchParams.has(param)) continue;
|
|
26
|
+
found = true;
|
|
27
|
+
const value = String(url.searchParams.get(param) || '').trim();
|
|
28
|
+
if (!conversationId && value) conversationId = value;
|
|
29
|
+
url.searchParams.delete(param);
|
|
30
|
+
}
|
|
31
|
+
return {
|
|
32
|
+
conversationId,
|
|
33
|
+
cleanedPath: found ? `${url.pathname}${url.search}${url.hash}` : null,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Reads and strips the deep link from the current location. */
|
|
38
|
+
export function consumeConversationDeepLink({ location = window.location, history = window.history, title = document.title } = {}) {
|
|
39
|
+
const { conversationId, cleanedPath } = readConversationDeepLink(location?.href);
|
|
40
|
+
if (cleanedPath !== null) history?.replaceState?.(null, title, cleanedPath);
|
|
41
|
+
return conversationId;
|
|
42
|
+
}
|
|
@@ -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 : {}),
|
|
@@ -1376,8 +1401,13 @@ function createMessageNode(msg, msgId = null, force = false) {
|
|
|
1376
1401
|
? `<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
1402
|
: '';
|
|
1378
1403
|
|
|
1404
|
+
// Shared viewers get the badge without the link to the other relay.
|
|
1405
|
+
const originBadgeHtml = remoteOrigin
|
|
1406
|
+
? renderRemoteOriginBadgeHtml(remoteOrigin, { linkable: !IS_SHARED_VIEW })
|
|
1407
|
+
: '';
|
|
1408
|
+
|
|
1379
1409
|
div.innerHTML = `
|
|
1380
|
-
<div class="${bubbleClass}">${shareVisibilityActionHtml}${thoughtsHtml}${content}${relayErrorCtaHtml}${attachmentHtml}${activityHtml}${subagentHtml}${workflowRunsHtml}${previewCardsHtml}${userBubbleActionsHtml}${stoppedSteerActionsHtml}</div>
|
|
1410
|
+
<div class="${bubbleClass}">${shareVisibilityActionHtml}${thoughtsHtml}${originBadgeHtml}${content}${relayErrorCtaHtml}${attachmentHtml}${activityHtml}${subagentHtml}${workflowRunsHtml}${previewCardsHtml}${userBubbleActionsHtml}${stoppedSteerActionsHtml}</div>
|
|
1381
1411
|
<div class="msg-label">${label}${modelTag}${reasoningTag}${modeTag}${autoTag}${continuationTag}${crossProviderTag}${usageTurnTag}${usageRemainingTag}${usageStaleTag} · ${fmtDate(msg.timestamp)}</div>`;
|
|
1382
1412
|
|
|
1383
1413
|
const bubble = div.querySelector('.msg-bubble');
|
|
@@ -1699,10 +1729,14 @@ export function showThinking(messageId = null, autoScroll = true) {
|
|
|
1699
1729
|
stopBtn.classList.toggle('stopping', stopping);
|
|
1700
1730
|
}
|
|
1701
1731
|
anchorThinkingBubble(existing, nextMessageId || existing.dataset.messageId);
|
|
1702
|
-
|
|
1732
|
+
syncThinkingFollowButtons(existing, getThinkingFollowMode());
|
|
1733
|
+
observeThinkingBubble(existing);
|
|
1734
|
+
if (getThinkingFollowMode()) applyThinkingFollow();
|
|
1735
|
+
else if (autoScroll) scrollBottom();
|
|
1703
1736
|
return;
|
|
1704
1737
|
}
|
|
1705
1738
|
existing?.remove();
|
|
1739
|
+
unobserveThinkingBubble();
|
|
1706
1740
|
const div = document.createElement('div');
|
|
1707
1741
|
div.className = 'msg assistant';
|
|
1708
1742
|
div.id = 'thinking-indicator';
|
|
@@ -1712,9 +1746,12 @@ export function showThinking(messageId = null, autoScroll = true) {
|
|
|
1712
1746
|
const stopBtnHtml = (!IS_SHARED_VIEW && nextMessageId)
|
|
1713
1747
|
? `<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
1748
|
: '';
|
|
1749
|
+
// Stop stays left; the follow buttons (💭 thoughts, 📃 answer, 🛠️ tools)
|
|
1750
|
+
// sit on the right. See thinking-follow.js.
|
|
1751
|
+
const followBtnsHtml = renderThinkingFollowButtonsHtml(getThinkingFollowMode());
|
|
1715
1752
|
div.innerHTML = `
|
|
1716
1753
|
<div class="thinking-bubble">
|
|
1717
|
-
<div class="thinking-bubble-header">${stopBtnHtml}</div>
|
|
1754
|
+
<div class="thinking-bubble-header">${stopBtnHtml}${followBtnsHtml}</div>
|
|
1718
1755
|
<details id="thinking-thoughts" class="thinking-thoughts-panel" open>
|
|
1719
1756
|
<summary>💭 Thoughts</summary>
|
|
1720
1757
|
<div class="thinking-thoughts-list"></div>
|
|
@@ -1727,15 +1764,49 @@ export function showThinking(messageId = null, autoScroll = true) {
|
|
|
1727
1764
|
anchorThinkingBubble(div, nextMessageId);
|
|
1728
1765
|
renderThinkingThoughts();
|
|
1729
1766
|
renderThinkingStream();
|
|
1730
|
-
|
|
1767
|
+
observeThinkingBubble(div);
|
|
1768
|
+
if (getThinkingFollowMode()) applyThinkingFollow();
|
|
1769
|
+
else if (autoScroll) scrollBottom();
|
|
1731
1770
|
}
|
|
1732
1771
|
|
|
1733
1772
|
export function removeThinking() {
|
|
1734
1773
|
thinkingMessageId = null;
|
|
1735
1774
|
lastInFlightSnapshotKey = '';
|
|
1775
|
+
unobserveThinkingBubble();
|
|
1736
1776
|
document.getElementById('thinking-indicator')?.remove();
|
|
1737
1777
|
}
|
|
1738
1778
|
|
|
1779
|
+
// Stop is one tap away on a sticky header, so a stray touch must not kill a
|
|
1780
|
+
// long turn: confirm first, then run the existing stop path.
|
|
1781
|
+
function openStopTurnConfirmation(conversationId, messageId) {
|
|
1782
|
+
const convId = String(conversationId || '').trim();
|
|
1783
|
+
const id = String(messageId || '').trim();
|
|
1784
|
+
if (!convId || !id) return;
|
|
1785
|
+
if (bubbleCancelInFlight.has(id)) return;
|
|
1786
|
+
openSummaryModal({
|
|
1787
|
+
title: 'Stop this turn?',
|
|
1788
|
+
subtitle: 'The agent stops after its current step',
|
|
1789
|
+
kind: 'stop-turn',
|
|
1790
|
+
bodyHtml: `
|
|
1791
|
+
<p>Stop the running turn? Work already done stays; anything the agent was about to do is cut short.</p>
|
|
1792
|
+
<div class="summary-modal-actions">
|
|
1793
|
+
<button class="chat-title-action-btn danger-btn" type="button" data-stop-turn-confirm="1">⏹ Stop turn</button>
|
|
1794
|
+
<button class="chat-title-action-btn" type="button" data-stop-turn-cancel="1">Keep running</button>
|
|
1795
|
+
</div>
|
|
1796
|
+
`,
|
|
1797
|
+
});
|
|
1798
|
+
const body = document.getElementById('summary-modal-body');
|
|
1799
|
+
body?.querySelector('[data-stop-turn-confirm]')?.addEventListener('click', (event) => {
|
|
1800
|
+
event.preventDefault();
|
|
1801
|
+
closeSummaryModal();
|
|
1802
|
+
void stopTurnByMessageId(convId, id);
|
|
1803
|
+
}, { once: true });
|
|
1804
|
+
body?.querySelector('[data-stop-turn-cancel]')?.addEventListener('click', (event) => {
|
|
1805
|
+
event.preventDefault();
|
|
1806
|
+
closeSummaryModal();
|
|
1807
|
+
}, { once: true });
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1739
1810
|
export function collapseThinkingThoughts() {
|
|
1740
1811
|
const panel = document.getElementById('thinking-thoughts');
|
|
1741
1812
|
if (!(panel instanceof HTMLDetailsElement)) return;
|
|
@@ -2919,7 +2990,7 @@ function runRelayErrorCta(cta) {
|
|
|
2919
2990
|
}
|
|
2920
2991
|
|
|
2921
2992
|
function handleBubbleActionClick(event) {
|
|
2922
|
-
const btn = event.target.closest('.bubble-action-btn, .msg-share-visibility-btn');
|
|
2993
|
+
const btn = event.target.closest('.bubble-action-btn, .msg-share-visibility-btn, .thinking-follow-btn');
|
|
2923
2994
|
if (!btn) return;
|
|
2924
2995
|
const action = btn.dataset.action;
|
|
2925
2996
|
const messageId = btn.dataset.messageId;
|
|
@@ -2944,7 +3015,15 @@ function handleBubbleActionClick(event) {
|
|
|
2944
3015
|
if (action === 'stop-turn' && messageId) {
|
|
2945
3016
|
event.preventDefault();
|
|
2946
3017
|
event.stopPropagation();
|
|
2947
|
-
|
|
3018
|
+
openStopTurnConfirmation(currentConvId, messageId);
|
|
3019
|
+
}
|
|
3020
|
+
|
|
3021
|
+
if (action === 'follow-thinking') {
|
|
3022
|
+
event.preventDefault();
|
|
3023
|
+
event.stopPropagation();
|
|
3024
|
+
const mode = toggleThinkingFollowMode(currentConvId, btn.dataset.follow);
|
|
3025
|
+
syncThinkingFollowButtons(document, mode);
|
|
3026
|
+
if (mode) applyThinkingFollow({ force: true });
|
|
2948
3027
|
}
|
|
2949
3028
|
|
|
2950
3029
|
if (action === 'cancel-queued' && messageId) {
|
|
@@ -2997,6 +3076,7 @@ export function initBubbleActionHandlers() {
|
|
|
2997
3076
|
if (!messagesEl) return;
|
|
2998
3077
|
messagesEl.addEventListener('click', handleBubbleActionClick);
|
|
2999
3078
|
messagesEl.addEventListener('click', handleEmbeddedImageClick);
|
|
3079
|
+
initThinkingFollow({ getCurrentConversationId: () => currentConvId });
|
|
3000
3080
|
}
|
|
3001
3081
|
|
|
3002
3082
|
export function appendMessage(msg, scroll = true, msgId = null, force = false, insertAfterId = null, trackHistory = true) {
|
|
@@ -3342,6 +3422,7 @@ export async function sendMessage() {
|
|
|
3342
3422
|
}
|
|
3343
3423
|
if (!hasDraft) return;
|
|
3344
3424
|
closeSlashAutocomplete();
|
|
3425
|
+
closeMentionAutocomplete();
|
|
3345
3426
|
|
|
3346
3427
|
// Warn-once typo guard: a message that looks like a command but matches none
|
|
3347
3428
|
// would otherwise burn an agent turn as plain text.
|
|
@@ -3589,6 +3670,12 @@ export async function sendMessage() {
|
|
|
3589
3670
|
};
|
|
3590
3671
|
window.syncAutoModelAvailability?.();
|
|
3591
3672
|
}
|
|
3673
|
+
// Adopting the conversation can grow the composer (session-lock note,
|
|
3674
|
+
// warning banner), which shrinks the transcript under a pane that was
|
|
3675
|
+
// pinned to the newest message. Re-pin now rather than after the draft
|
|
3676
|
+
// save's round trip below, or the new bubble sits under the composer
|
|
3677
|
+
// for as long as that request takes.
|
|
3678
|
+
if (viewingSendConversation()) scrollBottom();
|
|
3592
3679
|
const persistedConversationId = String(r.conversationId || targetConversationId || '').trim();
|
|
3593
3680
|
if (persistedConversationId) {
|
|
3594
3681
|
restorableDraftByConversation.delete(persistedConversationId);
|
|
@@ -3661,12 +3748,16 @@ export async function sendMessage() {
|
|
|
3661
3748
|
// menu in sync with the caret. Cheap no-op for non-slash text.
|
|
3662
3749
|
export function updateComposerSlashMenu(input) {
|
|
3663
3750
|
updateSlashAutocomplete(input, { conversationId: currentConvId });
|
|
3751
|
+
// Second, so it sees the slash menu's verdict: the @relay popup never opens
|
|
3752
|
+
// over an open slash menu, and at most one of them ever handles keys.
|
|
3753
|
+
updateMentionAutocomplete(input);
|
|
3664
3754
|
}
|
|
3665
3755
|
|
|
3666
3756
|
export function handleKey(e) {
|
|
3667
3757
|
// The open menu owns navigation keys; Ctrl/Cmd+Enter still falls through to
|
|
3668
|
-
// send because
|
|
3758
|
+
// send because neither menu consumes it.
|
|
3669
3759
|
if (handleSlashAutocompleteKey(e, e.target)) return;
|
|
3760
|
+
if (handleMentionAutocompleteKey(e, e.target)) return;
|
|
3670
3761
|
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
|
3671
3762
|
e.preventDefault();
|
|
3672
3763
|
sendMessage();
|