@oar-sh/oar 0.9.8 → 0.9.10

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 (107) hide show
  1. package/CHANGELOG.md +478 -0
  2. package/README.md +129 -39
  3. package/bin/bat/oar.bat +46 -46
  4. package/bin/oar.js +814 -228
  5. package/package.json +5 -6
  6. package/server/README.md +306 -4
  7. package/server/claude-worker/claude-remote-relay-tool.mjs +10 -0
  8. package/server/claude-worker/claude-session-process.mjs +35 -1
  9. package/server/claude-worker/claude-turn-publisher.mjs +3 -0
  10. package/server/claude-worker/sdk-message-normalizer.mjs +31 -4
  11. package/server/claude-worker/subagent-timeline-recorder.mjs +572 -0
  12. package/server/copilot-worker/copilot-sdk-session-process.mjs +3 -1
  13. package/server/cursor-worker/cursor-sdk-adapter.mjs +56 -2
  14. package/server/db-schema.mjs +44 -0
  15. package/server/migrations/0008-message-pins.mjs +85 -0
  16. package/server/migrations/0009-subagent-timeline.mjs +135 -0
  17. package/server/migrations/0010-subagent-timeline-pruning.mjs +89 -0
  18. package/server/migrations/0011-relay-event-subscriptions.mjs +125 -0
  19. package/server/migrations/0012-cross-relay-event-subscriptions.mjs +116 -0
  20. package/server/public/app/agent-exchange-fold-view.mjs +248 -0
  21. package/server/public/app/api-client.js +91 -0
  22. package/server/public/app/archive-confirm.mjs +28 -0
  23. package/server/public/app/ask-user-view.js +3 -0
  24. package/server/public/app/attachments-view.js +1024 -131
  25. package/server/public/app/back-guard.mjs +58 -0
  26. package/server/public/app/background-tasks-view.mjs +18 -26
  27. package/server/public/app/bootstrap.js +230 -35
  28. package/server/public/app/chat-actions-menu-state.mjs +116 -0
  29. package/server/public/app/claude-cloud-conversation-ui.js +10 -22
  30. package/server/public/app/composer-control-state.mjs +16 -0
  31. package/server/public/app/composer-keyboard.mjs +54 -0
  32. package/server/public/app/conversation-view.js +471 -110
  33. package/server/public/app/explorer-edit-state.mjs +68 -0
  34. package/server/public/app/external-link-policy.mjs +4 -1
  35. package/server/public/app/file-version.mjs +58 -0
  36. package/server/public/app/host-suspend-ui.js +6 -3
  37. package/server/public/app/journal-view.js +161 -27
  38. package/server/public/app/no-conversation-state.mjs +58 -0
  39. package/server/public/app/pinned-messages-view.mjs +253 -0
  40. package/server/public/app/provider-readiness.mjs +120 -0
  41. package/server/public/app/pwa-install.js +1 -1
  42. package/server/public/app/relay-board-view.js +3 -0
  43. package/server/public/app/relay-dot-state.mjs +8 -1
  44. package/server/public/app/relay-event-subscriptions-ui.js +184 -0
  45. package/server/public/app/relay-event-view.mjs +234 -0
  46. package/server/public/app/router.js +8 -2
  47. package/server/public/app/settings-modal.js +53 -1
  48. package/server/public/app/socket-handlers.js +65 -28
  49. package/server/public/app/store.js +25 -2
  50. package/server/public/app/subagent-row.mjs +138 -0
  51. package/server/public/app/subagent-timeline-view.mjs +186 -0
  52. package/server/public/app/subagent-viewer-watch.mjs +50 -0
  53. package/server/public/app/subagent-viewer.mjs +534 -0
  54. package/server/public/app/task-row-format.mjs +29 -0
  55. package/server/public/app/thinking-follow.js +4 -0
  56. package/server/public/app/transcript-jump-bottom-view.mjs +290 -0
  57. package/server/public/app/transcript-jump-bottom.mjs +99 -0
  58. package/server/public/app/transcript-reload-freshness.mjs +25 -0
  59. package/server/public/app/update-ui.js +1 -1
  60. package/server/public/app/viewer-chat-gallery.mjs +59 -0
  61. package/server/public/app/viewer-controls-state.mjs +59 -0
  62. package/server/public/app/viewer-gallery.mjs +46 -0
  63. package/server/public/app/viewer-gestures.mjs +105 -0
  64. package/server/public/app/viewer-history.mjs +45 -0
  65. package/server/public/app/viewer-tap.mjs +112 -0
  66. package/server/public/index.html +700 -59
  67. package/server/repositories/message-pin-statements.mjs +39 -0
  68. package/server/repositories/message-repository.mjs +34 -3
  69. package/server/repositories/question-repository.mjs +9 -3
  70. package/server/repositories/remote-relay-repository.mjs +6 -2
  71. package/server/repositories/subagent-timeline-statements.mjs +170 -0
  72. package/server/routes/ask-user-routes.mjs +3 -0
  73. package/server/routes/messages-routes.mjs +548 -111
  74. package/server/routes/relay-event-routes.mjs +157 -0
  75. package/server/routes/remote-relay-routes.mjs +15 -0
  76. package/server/routes/sessions-routes.mjs +164 -24
  77. package/server/server-runtime.mjs +274 -62
  78. package/server/services/cloudflared-tunnel-service.mjs +139 -41
  79. package/server/services/explorer-entry-name.mjs +26 -0
  80. package/server/services/explorer-mutations.mjs +122 -0
  81. package/server/services/file-serving.mjs +155 -0
  82. package/server/services/message-pin-list.mjs +52 -0
  83. package/server/services/oar-cli-helpers.mjs +139 -4
  84. package/server/services/oar-cli-menu.mjs +240 -0
  85. package/server/services/oar-cli-wsl.mjs +129 -0
  86. package/server/services/preview-proxy-server.mjs +41 -23
  87. package/server/services/push-dispatch-service.mjs +18 -0
  88. package/server/services/relay-event-link-service.mjs +289 -0
  89. package/server/services/relay-event-service.mjs +1935 -0
  90. package/server/services/relay-shutdown-gate.mjs +31 -0
  91. package/server/services/remote-relay-caller-context.mjs +52 -7
  92. package/server/services/remote-relay-dispatcher.mjs +494 -2
  93. package/server/services/remote-relay-registry-service.mjs +11 -1
  94. package/server/services/safe-served-content.mjs +61 -11
  95. package/server/services/session-activity-tracker.mjs +487 -0
  96. package/server/services/session-history-refresh-service.mjs +31 -0
  97. package/server/services/stale-attempt-log-limiter.mjs +96 -0
  98. package/server/services/stale-recovery-clock-guard.mjs +57 -0
  99. package/server/services/stale-subagent-run-sweep.mjs +109 -0
  100. package/server/services/subagent-record-retention.mjs +164 -0
  101. package/server/services/subagent-timeline-service.mjs +328 -0
  102. package/server/services/subagent-viewer-rooms.mjs +119 -0
  103. package/server/services/upgrade-socket-guard.mjs +16 -0
  104. package/shared/relay-events.mjs +371 -0
  105. package/shared/remote-relay-contract.mjs +157 -5
  106. package/shared/subagent-record-retention.mjs +31 -0
  107. package/shared/subagent-timeline.mjs +79 -0
@@ -0,0 +1,253 @@
1
+ // Pinned messages: the per-conversation pin list, the 📍 header button with
2
+ // its count, and the list modal (the shared summary modal, kind
3
+ // 'pinned-messages'). Fed by the conversation payload's `pins` on load and by
4
+ // the `conversation_pins_updated` socket event (REPLACE semantics per
5
+ // conversation), so a pin set on another device shows here without a reload.
6
+ // This list is also what a bubble's Pin / Unpin label is drawn from.
7
+
8
+ import {
9
+ IS_SHARED_VIEW,
10
+ closeSummaryModal,
11
+ escHtml,
12
+ fmtDate,
13
+ openSummaryModal,
14
+ renderSummaryModalContent,
15
+ showTransientRelayNotice,
16
+ summaryModalState,
17
+ } from './store.js';
18
+ import { updateMessagePin } from './api-client.js';
19
+
20
+ export const PINNED_MESSAGES_MODAL_KIND = 'pinned-messages';
21
+
22
+ const pinsByConversation = new Map();
23
+ // The relay numbers its pin lists. A conversation load that left before a pin
24
+ // change can arrive after the change's own socket event: the older list must
25
+ // not win, or the page would stay wrong until the next load.
26
+ const pinRevisionByConversation = new Map();
27
+ const pinTogglesInFlight = new Set();
28
+ let currentConversationId = null;
29
+ let modalBodyBound = false;
30
+ // The transcript lives in conversation-view.js and journal-view.js, which
31
+ // import this module; they hand their side in through initPinnedMessagesView.
32
+ let handlers = {
33
+ openConversation: null,
34
+ focusMessage: null,
35
+ syncRenderedPinState: null,
36
+ };
37
+
38
+ function normalizePins(pins) {
39
+ return (Array.isArray(pins) ? pins : [])
40
+ .map((pin) => ({ ...pin, messageId: String(pin?.messageId || '').trim() }))
41
+ .filter((pin) => pin.messageId);
42
+ }
43
+
44
+ export function getConversationPins(conversationId) {
45
+ return pinsByConversation.get(String(conversationId || '').trim()) || [];
46
+ }
47
+
48
+ export function isMessagePinned(conversationId, messageId) {
49
+ const id = String(messageId || '').trim();
50
+ return !!id && getConversationPins(conversationId).some((pin) => pin.messageId === id);
51
+ }
52
+
53
+ export function pinnedCountLabel(count) {
54
+ const total = Number(count) || 0;
55
+ return total ? `${total} pinned` : '';
56
+ }
57
+
58
+ function pinRoleLabel(role) {
59
+ return String(role || '').trim().toLowerCase() === 'user' ? 'You' : 'Agent';
60
+ }
61
+
62
+ export function renderPinnedListHtml(pins) {
63
+ const list = normalizePins(pins);
64
+ if (!list.length) {
65
+ return '<div class="pinned-empty">No pinned messages. Use Pin on a message to add one.</div>';
66
+ }
67
+ return `<div class="pinned-list">${list.map((pin) => {
68
+ const id = escHtml(pin.messageId);
69
+ const when = fmtDate(pin.timestamp);
70
+ const attachmentCount = Number(pin.attachmentCount) || 0;
71
+ const meta = [
72
+ escHtml(pinRoleLabel(pin.role)),
73
+ when ? escHtml(when) : '',
74
+ attachmentCount ? `📎 ${attachmentCount}` : '',
75
+ pin.hiddenFromShares === true ? '<span class="pinned-row-chip">hidden from shared</span>' : '',
76
+ ].filter(Boolean).join(' · ');
77
+ const preview = String(pin.preview || '').trim();
78
+ const previewHtml = preview
79
+ ? escHtml(preview)
80
+ : '<span class="pinned-row-no-text">(no text)</span>';
81
+ return `
82
+ <div class="pinned-row">
83
+ <button type="button" class="pinned-row-jump" data-pin-jump="${id}">
84
+ <span class="pinned-row-meta">${meta}</span>
85
+ <span class="pinned-row-preview">${previewHtml}</span>
86
+ </button>
87
+ <button type="button" class="pinned-row-unpin" data-pin-unpin="${id}" title="Unpin" aria-label="Unpin">🗑</button>
88
+ </div>`;
89
+ }).join('')}</div>`;
90
+ }
91
+
92
+ function syncPinnedHeaderButton() {
93
+ const button = document.getElementById('pinned-messages-btn');
94
+ if (!button) return;
95
+ const count = getConversationPins(currentConversationId).length;
96
+ button.hidden = IS_SHARED_VIEW || !count;
97
+ const countEl = button.querySelector('.header-icon-count');
98
+ if (countEl) countEl.textContent = count ? String(count) : '';
99
+ const label = count ? `Pinned messages (${count})` : 'Pinned messages';
100
+ button.setAttribute('title', label);
101
+ button.setAttribute('aria-label', label);
102
+ }
103
+
104
+ function pinnedModalContent() {
105
+ const pins = getConversationPins(currentConversationId);
106
+ return {
107
+ title: '📍 Pinned messages',
108
+ subtitle: pinnedCountLabel(pins.length),
109
+ bodyHtml: renderPinnedListHtml(pins),
110
+ kind: PINNED_MESSAGES_MODAL_KIND,
111
+ };
112
+ }
113
+
114
+ function syncCurrentConversationPins() {
115
+ syncPinnedHeaderButton();
116
+ if (summaryModalState.kind === PINNED_MESSAGES_MODAL_KIND) {
117
+ renderSummaryModalContent(pinnedModalContent());
118
+ }
119
+ handlers.syncRenderedPinState?.(
120
+ new Set(getConversationPins(currentConversationId).map((pin) => pin.messageId)),
121
+ );
122
+ }
123
+
124
+ export function setConversationPins(conversationId, pins, revision = 0) {
125
+ const id = String(conversationId || '').trim();
126
+ if (!id) return;
127
+ const nextRevision = Number(revision) || 0;
128
+ if (nextRevision) {
129
+ if (nextRevision < (pinRevisionByConversation.get(id) || 0)) return;
130
+ pinRevisionByConversation.set(id, nextRevision);
131
+ }
132
+ const normalized = normalizePins(pins);
133
+ // The list arrives with every conversation load, the live poll included: an
134
+ // unchanged one must not redraw the open modal under the user's finger.
135
+ if (JSON.stringify(normalized) === JSON.stringify(getConversationPins(id))) return;
136
+ if (normalized.length) pinsByConversation.set(id, normalized);
137
+ else pinsByConversation.delete(id);
138
+ if (id === currentConversationId) syncCurrentConversationPins();
139
+ }
140
+
141
+ export function setPinsConversation(conversationId) {
142
+ const id = String(conversationId || '').trim() || null;
143
+ if (id === currentConversationId) return;
144
+ currentConversationId = id;
145
+ // The conversation was closed (deleted, new chat): its list goes with it.
146
+ if (!id && summaryModalState.kind === PINNED_MESSAGES_MODAL_KIND) closeSummaryModal();
147
+ syncCurrentConversationPins();
148
+ }
149
+
150
+ export function openPinnedMessagesModal() {
151
+ if (IS_SHARED_VIEW) return;
152
+ openSummaryModal(pinnedModalContent());
153
+ }
154
+
155
+ function setPinButtonBusy(messageId, busy, pinned) {
156
+ const button = document.querySelector(`.msg-pin-btn[data-message-id="${CSS.escape(messageId)}"]`);
157
+ if (!button) return;
158
+ button.disabled = busy;
159
+ button.textContent = busy
160
+ ? (pinned ? 'Unpinning…' : 'Pinning…')
161
+ : (pinned ? 'Unpin' : 'Pin');
162
+ }
163
+
164
+ // `pinned` is the state the control showed when it was pressed; the request
165
+ // asks for the opposite. The answer carries the conversation's whole pin list,
166
+ // stored under that conversation's id: if the user has switched conversations
167
+ // meanwhile, the open one is not touched.
168
+ export async function toggleMessagePin(conversationId, messageId, pinned) {
169
+ const conversationKey = String(conversationId || '').trim();
170
+ const targetMessageId = String(messageId || '').trim();
171
+ if (!conversationKey || !targetMessageId) return;
172
+ const flightKey = `${conversationKey}:${targetMessageId}`;
173
+ if (pinTogglesInFlight.has(flightKey)) return;
174
+
175
+ pinTogglesInFlight.add(flightKey);
176
+ setPinButtonBusy(targetMessageId, true, pinned);
177
+ const result = await updateMessagePin(conversationKey, targetMessageId, !pinned);
178
+ pinTogglesInFlight.delete(flightKey);
179
+ if (!result?.ok) {
180
+ if (conversationKey === currentConversationId) setPinButtonBusy(targetMessageId, false, pinned);
181
+ showTransientRelayNotice(String(result?.error || '').trim() || (pinned
182
+ ? 'Could not unpin the message.'
183
+ : 'Could not pin the message.'));
184
+ return;
185
+ }
186
+
187
+ showTransientRelayNotice(result.pinned ? 'Message pinned.' : 'Message unpinned.');
188
+ if (conversationKey === currentConversationId) setPinButtonBusy(targetMessageId, false, result.pinned === true);
189
+ setConversationPins(conversationKey, result.pins, result.pinsRevision);
190
+ }
191
+
192
+ function isMessageOnPage(messageId) {
193
+ return !!document.querySelector(`#messages .msg[data-message-id="${CSS.escape(messageId)}"]`);
194
+ }
195
+
196
+ // On the page already: scroll there. Otherwise the path message search takes:
197
+ // the relay answers with the messages around the target and the history
198
+ // loaders page on from that window in both directions.
199
+ export async function jumpToPinnedMessage(messageId) {
200
+ const targetMessageId = String(messageId || '').trim();
201
+ const conversationId = currentConversationId;
202
+ if (!targetMessageId || !conversationId) return;
203
+ closeSummaryModal();
204
+ if (handlers.focusMessage?.(targetMessageId)) return;
205
+
206
+ await handlers.openConversation?.(conversationId, {
207
+ aroundMessageId: targetMessageId,
208
+ focusMessageId: targetMessageId,
209
+ });
210
+ if (currentConversationId !== conversationId || isMessageOnPage(targetMessageId)) return;
211
+ showTransientRelayNotice('That message is no longer in this conversation.');
212
+ // A message the relay no longer has answers with an empty window; put the
213
+ // end of the conversation back instead of leaving the page blank.
214
+ if (!document.querySelector('#messages .msg[data-message-id]')) {
215
+ await handlers.openConversation?.(conversationId);
216
+ }
217
+ }
218
+
219
+ function handlePinnedModalClick(event) {
220
+ if (summaryModalState.kind !== PINNED_MESSAGES_MODAL_KIND) return;
221
+ const target = event.target instanceof Element ? event.target : null;
222
+ const unpinBtn = target?.closest('[data-pin-unpin]');
223
+ if (unpinBtn) {
224
+ event.preventDefault();
225
+ void toggleMessagePin(currentConversationId, unpinBtn.getAttribute('data-pin-unpin'), true);
226
+ return;
227
+ }
228
+ const jumpBtn = target?.closest('[data-pin-jump]');
229
+ if (jumpBtn) {
230
+ event.preventDefault();
231
+ void jumpToPinnedMessage(jumpBtn.getAttribute('data-pin-jump'));
232
+ }
233
+ }
234
+
235
+ export function initPinnedMessagesView(nextHandlers = {}) {
236
+ handlers = { ...handlers, ...nextHandlers };
237
+ const button = document.getElementById('pinned-messages-btn');
238
+ if (button && button.dataset.bound !== '1') {
239
+ button.dataset.bound = '1';
240
+ button.addEventListener('click', (event) => {
241
+ event.preventDefault();
242
+ openPinnedMessagesModal();
243
+ });
244
+ }
245
+ // #summary-modal-body outlives every modal, so it is bound exactly once; the
246
+ // handler no-ops unless the pin list is the modal currently mounted.
247
+ const modalBody = document.getElementById('summary-modal-body');
248
+ if (modalBody && !modalBodyBound) {
249
+ modalBodyBound = true;
250
+ modalBody.addEventListener('click', handlePinnedModalClick);
251
+ }
252
+ syncPinnedHeaderButton();
253
+ }
@@ -0,0 +1,120 @@
1
+ // Which providers are known to be ready to run a chat, from what the client
2
+ // already loads: the provider settings payloads and the model catalog. No DOM.
3
+ //
4
+ // OpenAI, Claude, Claude Cloud, Cursor and Grok say so themselves: their
5
+ // settings carry `enabled`. Copilot has no such flag and the relay never asks
6
+ // whether the Copilot CLI is signed in, so the best signal is indirect: has a
7
+ // real Copilot model list ever reached the catalog? A fresh database is seeded
8
+ // with a curated list under the source `bootstrap-seed`; a server discovery,
9
+ // an extension snapshot or a worker snapshot replaces that source. Until one
10
+ // has, Copilot is `unknown`, never "not ready": the signal can be wrong (a
11
+ // signed-in host whose first discovery timed out), so callers only point to
12
+ // Settings and never block.
13
+ //
14
+ // A payload that failed to load is `null`, and makes the whole answer
15
+ // `known: false`. Callers treat an unknown answer as ready, which is today's
16
+ // behaviour: a network blip must never show the setup wall.
17
+
18
+ export const SEED_CATALOG_SOURCE = 'bootstrap-seed';
19
+
20
+ const SETTINGS_PROVIDERS = Object.freeze([
21
+ ['openai', 'openai'],
22
+ ['claude', 'claude'],
23
+ ['claudeCloud', 'claude-cloud'],
24
+ ['cursor', 'cursor'],
25
+ ['grok', 'grok'],
26
+ ]);
27
+
28
+ /** Payloads whose absence means "could not be loaded" rather than "not offered". */
29
+ const REQUIRED_INPUTS = Object.freeze(['openai', 'claude', 'cursor', 'grok', 'catalog']);
30
+
31
+ /**
32
+ * @param {object} input the settings payloads New Chat loads, plus GET /api/models
33
+ * @param {object|null} [input.openai]
34
+ * @param {object|null} [input.claude]
35
+ * @param {object|null} [input.claudeCloud] null on a relay without Claude Cloud: not offered, not unknown
36
+ * @param {object|null} [input.cursor]
37
+ * @param {object|null} [input.grok]
38
+ * @param {object|null} [input.catalog]
39
+ * @returns {{ known: boolean, anyReady: boolean, ready: string[], copilot: 'ready' | 'unknown' }}
40
+ */
41
+ export function resolveProviderReadiness(input = {}) {
42
+ const payloads = input && typeof input === 'object' ? input : {};
43
+ const known = REQUIRED_INPUTS.every((key) => payloads[key] && typeof payloads[key] === 'object');
44
+ const ready = [];
45
+ for (const [key, providerId] of SETTINGS_PROVIDERS) {
46
+ if (payloads[key]?.enabled === true) ready.push(providerId);
47
+ }
48
+ const source = String(payloads.catalog?.source || '').trim();
49
+ const copilot = payloads.catalog && typeof payloads.catalog === 'object' && source && source !== SEED_CATALOG_SOURCE
50
+ ? 'ready'
51
+ : 'unknown';
52
+ if (copilot === 'ready') ready.push('github');
53
+ return { known, anyReady: ready.length > 0, ready, copilot };
54
+ }
55
+
56
+ /** The one question the empty screen, New Chat and the composer ask. */
57
+ export function isSetupRequired(readiness) {
58
+ return !!readiness && readiness.known === true && readiness.anyReady !== true;
59
+ }
60
+
61
+ // ── The client's live copy ────────────────────────────────────────────────
62
+ // Fed from the initial loads, the socket events that follow a Settings save
63
+ // or a catalog change, and New Chat's own fresh loads. Subscribers redraw the
64
+ // empty screen and the composer when the answer changes.
65
+
66
+ const inputs = {
67
+ openai: undefined,
68
+ claude: undefined,
69
+ claudeCloud: undefined,
70
+ cursor: undefined,
71
+ grok: undefined,
72
+ catalog: undefined,
73
+ };
74
+ let current = resolveProviderReadiness(inputs);
75
+ const listeners = new Set();
76
+
77
+ function readinessKey(readiness) {
78
+ return `${readiness.known ? 1 : 0}|${readiness.anyReady ? 1 : 0}|${readiness.ready.join(',')}|${readiness.copilot}`;
79
+ }
80
+
81
+ /**
82
+ * Records fresh payloads; `null` for a failed load, `undefined` keys are left
83
+ * as they were. Listeners run only when the resolved answer changed.
84
+ */
85
+ export function updateProviderReadinessInputs(partial = {}) {
86
+ if (!partial || typeof partial !== 'object') return current;
87
+ for (const key of Object.keys(inputs)) {
88
+ if (partial[key] !== undefined) inputs[key] = partial[key];
89
+ }
90
+ const next = resolveProviderReadiness(inputs);
91
+ const changed = readinessKey(next) !== readinessKey(current);
92
+ current = next;
93
+ if (changed) {
94
+ for (const listener of Array.from(listeners)) {
95
+ try { listener(current); } catch {}
96
+ }
97
+ }
98
+ return current;
99
+ }
100
+
101
+ export function getProviderReadiness() {
102
+ return current;
103
+ }
104
+
105
+ export function isProviderSetupRequired() {
106
+ return isSetupRequired(current);
107
+ }
108
+
109
+ export function subscribeProviderReadiness(listener) {
110
+ if (typeof listener !== 'function') return () => {};
111
+ listeners.add(listener);
112
+ return () => { listeners.delete(listener); };
113
+ }
114
+
115
+ /** Tests only: back to "nothing loaded yet". */
116
+ export function resetProviderReadinessForTests() {
117
+ for (const key of Object.keys(inputs)) inputs[key] = undefined;
118
+ current = resolveProviderReadiness(inputs);
119
+ listeners.clear();
120
+ }
@@ -25,7 +25,7 @@ function matchesDisplayMode(query) {
25
25
  }
26
26
  }
27
27
 
28
- function isInstalledAppMode() {
28
+ export function isInstalledAppMode() {
29
29
  const standalone = matchesDisplayMode('(display-mode: standalone)');
30
30
  const minimalUi = matchesDisplayMode('(display-mode: minimal-ui)');
31
31
  const launchedFromAndroidApp = String(document.referrer || '').startsWith('android-app://');
@@ -186,6 +186,9 @@ export function renderRelayBoards() {
186
186
  // the live bubble into the reply, or one of another conversation.
187
187
  el.querySelectorAll('.relay-board-inline').forEach((node) => { if (!placed.has(node)) node.remove(); });
188
188
  el.querySelectorAll('.relay-board-container').forEach((node) => { if (!node.querySelector('.relay-board-inline')) node.remove(); });
189
+ // A folded agent exchange whose turn waits on a board opens to show it
190
+ // (agent-exchange-fold-view.mjs).
191
+ window.syncAgentExchangeFolds?.();
189
192
  if (added) window.scrollBottom?.();
190
193
  }
191
194
 
@@ -9,7 +9,9 @@
9
9
  * A managed-but-disconnected tunnel stays green rather than going grey: the
10
10
  * relay itself is still reachable, and grey would claim otherwise. The tooltip
11
11
  * carries the bad news instead. Note this state is only ever visible locally —
12
- * a remote browser whose tunnel is down cannot load the page at all.
12
+ * a remote browser whose tunnel is down cannot load the page at all. A tunnel
13
+ * that never started because cloudflared is not installed reads "off" there,
14
+ * followed by the relay's sentence on how to install it.
13
15
  *
14
16
  * `tone` is the same state as `className`, minus the dot's CSS baggage. The
15
17
  * mobile burger toggle dyes its bars from it, because the dot itself sits in
@@ -24,6 +26,7 @@ function normalizeTunnel(tunnel) {
24
26
  if (!managed) return null;
25
27
  return {
26
28
  connected: tunnel.connected === true,
29
+ binaryMissing: tunnel.binaryMissing === true,
27
30
  lastError: String(tunnel.lastError || '').trim(),
28
31
  };
29
32
  }
@@ -56,6 +59,10 @@ export function resolveRelayDotState({
56
59
  let reach = 'Web relay reachable';
57
60
  if (tunnelled) {
58
61
  reach = 'Web relay reachable via Cloudflare Tunnel';
62
+ } else if (tunnel?.binaryMissing) {
63
+ reach = tunnel.lastError
64
+ ? `Web relay reachable; Cloudflare tunnel off: ${tunnel.lastError}`
65
+ : 'Web relay reachable; Cloudflare tunnel off: cloudflared is not installed';
59
66
  } else if (tunnel) {
60
67
  reach = tunnel.lastError
61
68
  ? `Web relay reachable; Cloudflare tunnel disconnected (${tunnel.lastError})`
@@ -0,0 +1,184 @@
1
+ // Relay event subscriptions on the page: the header's "watching N sessions"
2
+ // chip with its list and Unsubscribe buttons (on the watcher), and the
3
+ // "watched" badge (on a watched session, in the header and the sidebar).
4
+ // The relay sends the whole active list on every change (socket
5
+ // `relay_event_subscriptions`); the page loads it once at start.
6
+
7
+ import { apiFetch } from './api-client.js';
8
+ import { conversations, currentConvId, escHtml, IS_SHARED_VIEW, showTransientRelayNotice } from './store.js';
9
+ import {
10
+ renderWatchedBadgeHtml,
11
+ renderWatchingPanelHtml,
12
+ watchersOf,
13
+ watchingChipText,
14
+ watchingSummary,
15
+ } from './relay-event-view.mjs';
16
+
17
+ let subscriptions = [];
18
+ let renderedChipKey = '';
19
+ let panelOpen = false;
20
+ let panelConversationId = '';
21
+ const changeListeners = new Set();
22
+
23
+ export function getRelayEventSubscriptions() {
24
+ return subscriptions;
25
+ }
26
+
27
+ /** Runs after every change of the list (the sidebar re-renders its badges). */
28
+ export function onRelayEventSubscriptionsChanged(listener) {
29
+ changeListeners.add(listener);
30
+ return () => changeListeners.delete(listener);
31
+ }
32
+
33
+ export function applyRelayEventSubscriptions(list) {
34
+ subscriptions = Array.isArray(list) ? list.filter((sub) => sub && typeof sub === 'object') : [];
35
+ syncRelayEventHeader();
36
+ for (const listener of changeListeners) {
37
+ try { listener(subscriptions); } catch {}
38
+ }
39
+ }
40
+
41
+ export async function loadRelayEventSubscriptions() {
42
+ if (IS_SHARED_VIEW) return;
43
+ const response = await apiFetch('/api/relay-events/subscriptions');
44
+ if (response && Array.isArray(response.subscriptions)) applyRelayEventSubscriptions(response.subscriptions);
45
+ }
46
+
47
+ /** The sidebar's badge for one conversation: listed sessions only. */
48
+ export function relayEventWatchedMarkerHtml(conversationId) {
49
+ if (IS_SHARED_VIEW) return '';
50
+ return renderWatchedBadgeHtml(watchersOf(subscriptions, conversationId, { includeAll: false }), { conversations, subscriptions, escHtml });
51
+ }
52
+
53
+ async function unsubscribe(subscriptionId, button) {
54
+ if (!subscriptionId) return;
55
+ if (button) button.disabled = true;
56
+ const response = await apiFetch(`/api/relay-events/subscriptions/${encodeURIComponent(subscriptionId)}`, { method: 'DELETE' });
57
+ if (!response?.ok) {
58
+ if (button) button.disabled = false;
59
+ showTransientRelayNotice?.('The subscription could not be ended. Try again in a moment.');
60
+ return;
61
+ }
62
+ // The socket brings the new list; this page does not wait for it.
63
+ applyRelayEventSubscriptions(subscriptions.filter((sub) => sub.id !== subscriptionId));
64
+ }
65
+
66
+ // The list opens as a fixed popover on <body>, placed under the chip: the
67
+ // title slot clips its overflow (that keeps the title's ellipsis), so a panel
68
+ // inside it would be cut off.
69
+ function ensurePanel() {
70
+ let panel = document.getElementById('relay-watch-panel');
71
+ if (panel) return panel;
72
+ panel = document.createElement('div');
73
+ panel.id = 'relay-watch-panel';
74
+ panel.className = 'relay-watch-panel';
75
+ panel.hidden = true;
76
+ document.body.appendChild(panel);
77
+ panel.addEventListener('click', (event) => {
78
+ const target = event.target instanceof Element ? event.target : null;
79
+ const unsubscribeButton = target?.closest?.('.relay-watch-unsubscribe');
80
+ if (!unsubscribeButton) return;
81
+ void unsubscribe(unsubscribeButton.getAttribute('data-subscription-id'), unsubscribeButton);
82
+ });
83
+ document.addEventListener('click', (event) => {
84
+ if (!panelOpen) return;
85
+ // composedPath: an Unsubscribe click may have re-rendered the panel already.
86
+ const path = event.composedPath();
87
+ if (path.includes(panel) || path.some((node) => node instanceof Element && node.classList.contains('relay-watch-chip'))) return;
88
+ setPanelOpen(false);
89
+ });
90
+ document.addEventListener('keydown', (event) => {
91
+ if (!panelOpen || event.key !== 'Escape') return;
92
+ event.stopPropagation();
93
+ setPanelOpen(false);
94
+ document.querySelector('#chat-title-relay-events .relay-watch-chip')?.focus?.();
95
+ }, true);
96
+ window.addEventListener('resize', () => { if (panelOpen) placePanel(); });
97
+ return panel;
98
+ }
99
+
100
+ function setPanelOpen(open) {
101
+ panelOpen = Boolean(open);
102
+ renderedChipKey = '';
103
+ syncRelayEventHeader();
104
+ }
105
+
106
+ function placePanel() {
107
+ const panel = document.getElementById('relay-watch-panel');
108
+ const chip = document.querySelector('#chat-title-relay-events .relay-watch-chip');
109
+ if (!panel || panel.hidden) return;
110
+ const rect = chip?.getBoundingClientRect?.();
111
+ if (!rect || (!rect.width && !rect.height)) {
112
+ panel.hidden = true;
113
+ return;
114
+ }
115
+ const margin = 8;
116
+ const viewportWidth = document.documentElement.clientWidth || window.innerWidth;
117
+ const top = Math.round(rect.bottom + 4);
118
+ panel.style.top = `${top}px`;
119
+ panel.style.maxHeight = `${Math.max(120, window.innerHeight - top - margin)}px`;
120
+ panel.style.left = '0px';
121
+ const width = panel.offsetWidth;
122
+ const left = Math.max(margin, Math.min(rect.left, viewportWidth - width - margin));
123
+ panel.style.left = `${Math.round(left)}px`;
124
+ }
125
+
126
+ function ensureHeaderElements() {
127
+ const slot = document.getElementById('chat-title-slot');
128
+ if (!slot) return null;
129
+ let line = document.getElementById('chat-title-relay-events');
130
+ if (!line) {
131
+ line = document.createElement('span');
132
+ line.id = 'chat-title-relay-events';
133
+ line.hidden = true;
134
+ const origin = document.getElementById('chat-title-origin');
135
+ if (origin?.nextSibling) slot.insertBefore(line, origin.nextSibling);
136
+ else slot.appendChild(line);
137
+ line.addEventListener('click', (event) => {
138
+ const target = event.target instanceof Element ? event.target : null;
139
+ if (target?.closest?.('.relay-watch-chip')) {
140
+ event.stopPropagation();
141
+ setPanelOpen(!panelOpen);
142
+ }
143
+ });
144
+ }
145
+ return line;
146
+ }
147
+
148
+ /** The header's chip and badge for the conversation on screen. Idempotent. */
149
+ export function syncRelayEventHeader() {
150
+ const line = ensureHeaderElements();
151
+ if (!line) return;
152
+ const panel = ensurePanel();
153
+ const id = String(currentConvId || '').trim();
154
+ // Another conversation on screen closes the list.
155
+ if (id !== panelConversationId) panelOpen = false;
156
+ panelConversationId = id;
157
+ if (IS_SHARED_VIEW || !id) {
158
+ line.hidden = true;
159
+ line.innerHTML = '';
160
+ panel.hidden = true;
161
+ panel.innerHTML = '';
162
+ renderedChipKey = '';
163
+ return;
164
+ }
165
+ const summary = watchingSummary(subscriptions, id);
166
+ const chipText = watchingChipText(summary);
167
+ const watchers = watchersOf(subscriptions, id, { includeAll: true });
168
+ if (!chipText) panelOpen = false;
169
+ const key = JSON.stringify([id, chipText, panelOpen, summary.subscriptions.map((sub) => [sub.id, sub.sessions, sub.events]), watchers]);
170
+ if (key === renderedChipKey) return;
171
+ renderedChipKey = key;
172
+ const parts = [];
173
+ if (chipText) {
174
+ const where = summary.relays.length ? ` (also on ${summary.relays.join(', ')})` : '';
175
+ parts.push(`<button type="button" class="relay-watch-chip" aria-expanded="${panelOpen ? 'true' : 'false'}" aria-controls="relay-watch-panel" title="${escHtml(`Event subscriptions of this conversation${where}`)}">👁 ${escHtml(chipText)}</button>`);
176
+ }
177
+ const badge = renderWatchedBadgeHtml(watchers, { conversations, subscriptions, escHtml });
178
+ if (badge) parts.push(badge);
179
+ line.innerHTML = parts.join('');
180
+ line.hidden = parts.length === 0;
181
+ panel.innerHTML = panelOpen ? renderWatchingPanelHtml(summary, { conversations, escHtml }) : '';
182
+ panel.hidden = !panelOpen;
183
+ if (panelOpen) placePanel();
184
+ }