@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.
Files changed (76) hide show
  1. package/.github/extensions/web-relay/extension.mjs +6 -1
  2. package/.github/extensions/web-relay/skills/tool-activity.mjs +7 -0
  3. package/CHANGELOG.md +98 -0
  4. package/README.md +76 -14
  5. package/package.json +5 -2
  6. package/server/README.md +43 -3
  7. package/server/claude-worker/claude-remote-relay-tool.mjs +100 -0
  8. package/server/claude-worker/claude-sdk-adapter.mjs +26 -4
  9. package/server/claude-worker/claude-session-process.mjs +385 -43
  10. package/server/claude-worker/claude-session-worker.mjs +5 -0
  11. package/server/claude-worker/sdk-message-normalizer.mjs +7 -0
  12. package/server/copilot-worker/copilot-remote-relay-tool.mjs +59 -0
  13. package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +12 -2
  14. package/server/copilot-worker/copilot-sdk-session-process.mjs +83 -9
  15. package/server/copilot-worker/copilot-sdk-session-worker.mjs +5 -0
  16. package/server/cursor-worker/cursor-remote-relay-tool.mjs +65 -0
  17. package/server/cursor-worker/cursor-session-worker.mjs +5 -0
  18. package/server/cursor-worker/cursor-turn-runner.mjs +45 -2
  19. package/server/cursor-worker/sdk-message-normalizer.mjs +25 -0
  20. package/server/db-schema.mjs +8 -0
  21. package/server/grok-worker/acp-client.mjs +92 -6
  22. package/server/grok-worker/grok-session-worker.mjs +5 -0
  23. package/server/grok-worker/grok-turn-runner.mjs +59 -8
  24. package/server/grok-worker/sdk-message-normalizer.mjs +13 -0
  25. package/server/mcp/oar-mcp-server.mjs +473 -0
  26. package/server/migrations/0005-remote-relays.mjs +90 -0
  27. package/server/public/app/action-confirmations.js +141 -13
  28. package/server/public/app/api-client.js +103 -0
  29. package/server/public/app/bootstrap.js +21 -6
  30. package/server/public/app/conversation-deep-link.mjs +42 -0
  31. package/server/public/app/conversation-view.js +100 -7
  32. package/server/public/app/host-suspend-ui.js +272 -0
  33. package/server/public/app/journal-view.js +4 -1
  34. package/server/public/app/mention-autocomplete.mjs +298 -0
  35. package/server/public/app/push-settings.js +1 -0
  36. package/server/public/app/pwa-install.js +8 -3
  37. package/server/public/app/relay-prompt-sanitizer.mjs +48 -5
  38. package/server/public/app/remote-relay-origin-view.mjs +76 -0
  39. package/server/public/app/remote-relay-shared.mjs +85 -0
  40. package/server/public/app/remote-relays-settings.js +618 -0
  41. package/server/public/app/remote-relays-store.mjs +126 -0
  42. package/server/public/app/settings-modal.js +4 -2
  43. package/server/public/app/settings-tabs.js +1 -1
  44. package/server/public/app/socket-handlers.js +18 -1
  45. package/server/public/app/steered-turn-merge.mjs +10 -1
  46. package/server/public/app/thinking-follow.js +260 -0
  47. package/server/public/app/tool-activity-emoji.mjs +4 -0
  48. package/server/public/index.html +264 -11
  49. package/server/relay-tools.md +5 -1
  50. package/server/repositories/message-repository.mjs +7 -0
  51. package/server/repositories/question-repository.mjs +3 -0
  52. package/server/repositories/remote-relay-repository.mjs +75 -0
  53. package/server/repositories/session-repository.mjs +6 -1
  54. package/server/routes/ask-user-routes.mjs +42 -2
  55. package/server/routes/messages-routes.mjs +98 -19
  56. package/server/routes/remote-relay-routes.mjs +133 -0
  57. package/server/routes/sessions-routes.mjs +84 -31
  58. package/server/server-runtime.mjs +238 -0
  59. package/server/services/github-ci-activity-service.mjs +199 -0
  60. package/server/services/host-suspend-command.mjs +38 -0
  61. package/server/services/host-suspend-service.mjs +254 -0
  62. package/server/services/push-dispatch-service.mjs +37 -4
  63. package/server/services/remote-relay-caller-context.mjs +346 -0
  64. package/server/services/remote-relay-client.mjs +332 -0
  65. package/server/services/remote-relay-dispatcher.mjs +1743 -0
  66. package/server/services/remote-relay-inbound.mjs +187 -0
  67. package/server/services/remote-relay-pairing.mjs +189 -0
  68. package/server/services/remote-relay-registry-service.mjs +581 -0
  69. package/server/services/session-worker-launch-service.mjs +148 -14
  70. package/shared/remote-relay-contract.mjs +556 -0
  71. package/shared/remote-relay-mentions.mjs +129 -0
  72. package/shared/remote-relay-tool-core.mjs +314 -0
  73. package/shared/steer-settle-failure.mjs +6 -0
  74. package/shared/steer-settle-markers.mjs +16 -0
  75. package/shared/worker-runtime/api-client.mjs +112 -22
  76. package/shared/worker-runtime/worker-log-file.mjs +97 -0
@@ -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: 'Requests suspend-to-RAM',
139
- kind: 'suspend-host',
140
- bodyHtml: `
141
- <p>Put this PC to sleep now?</p>
142
- <p>This requests suspend-to-RAM immediately.</p>
143
- <div class="summary-modal-actions">
144
- <button class="chat-title-action-btn" type="button" onclick="confirmSuspendHost()">💤 Suspend host</button>
145
- <button class="chat-title-action-btn" type="button" onclick="closeSummaryModal()">Cancel</button>
146
- </div>
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 now?\n\nThis requests suspend-to-RAM.');
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 suspend host');
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
- const url = new URL(window.location.href);
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
+ }