@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.
Files changed (68) 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 +67 -0
  4. package/README.md +62 -13
  5. package/package.json +5 -2
  6. package/server/README.md +43 -3
  7. package/server/claude-worker/claude-remote-relay-tool.mjs +99 -0
  8. package/server/claude-worker/claude-sdk-adapter.mjs +26 -4
  9. package/server/claude-worker/claude-session-process.mjs +92 -25
  10. package/server/claude-worker/sdk-message-normalizer.mjs +7 -0
  11. package/server/copilot-worker/copilot-remote-relay-tool.mjs +59 -0
  12. package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +12 -2
  13. package/server/copilot-worker/copilot-sdk-session-process.mjs +83 -9
  14. package/server/cursor-worker/cursor-remote-relay-tool.mjs +65 -0
  15. package/server/cursor-worker/cursor-turn-runner.mjs +45 -2
  16. package/server/cursor-worker/sdk-message-normalizer.mjs +25 -0
  17. package/server/db-schema.mjs +8 -0
  18. package/server/grok-worker/acp-client.mjs +92 -6
  19. package/server/grok-worker/grok-turn-runner.mjs +59 -8
  20. package/server/grok-worker/sdk-message-normalizer.mjs +13 -0
  21. package/server/mcp/oar-mcp-server.mjs +473 -0
  22. package/server/migrations/0005-remote-relays.mjs +90 -0
  23. package/server/public/app/action-confirmations.js +141 -13
  24. package/server/public/app/api-client.js +103 -0
  25. package/server/public/app/bootstrap.js +21 -6
  26. package/server/public/app/conversation-deep-link.mjs +42 -0
  27. package/server/public/app/conversation-view.js +98 -7
  28. package/server/public/app/host-suspend-ui.js +272 -0
  29. package/server/public/app/journal-view.js +4 -1
  30. package/server/public/app/mention-autocomplete.mjs +298 -0
  31. package/server/public/app/push-settings.js +1 -0
  32. package/server/public/app/pwa-install.js +8 -3
  33. package/server/public/app/relay-prompt-sanitizer.mjs +48 -5
  34. package/server/public/app/remote-relay-origin-view.mjs +76 -0
  35. package/server/public/app/remote-relay-shared.mjs +85 -0
  36. package/server/public/app/remote-relays-settings.js +618 -0
  37. package/server/public/app/remote-relays-store.mjs +126 -0
  38. package/server/public/app/settings-modal.js +4 -2
  39. package/server/public/app/settings-tabs.js +1 -1
  40. package/server/public/app/socket-handlers.js +18 -1
  41. package/server/public/app/thinking-follow.js +260 -0
  42. package/server/public/app/tool-activity-emoji.mjs +4 -0
  43. package/server/public/index.html +257 -8
  44. package/server/relay-tools.md +5 -1
  45. package/server/repositories/message-repository.mjs +7 -0
  46. package/server/repositories/question-repository.mjs +3 -0
  47. package/server/repositories/remote-relay-repository.mjs +75 -0
  48. package/server/repositories/session-repository.mjs +6 -1
  49. package/server/routes/ask-user-routes.mjs +42 -2
  50. package/server/routes/messages-routes.mjs +90 -16
  51. package/server/routes/remote-relay-routes.mjs +133 -0
  52. package/server/routes/sessions-routes.mjs +80 -29
  53. package/server/server-runtime.mjs +238 -0
  54. package/server/services/github-ci-activity-service.mjs +199 -0
  55. package/server/services/host-suspend-command.mjs +38 -0
  56. package/server/services/host-suspend-service.mjs +254 -0
  57. package/server/services/push-dispatch-service.mjs +37 -4
  58. package/server/services/remote-relay-caller-context.mjs +341 -0
  59. package/server/services/remote-relay-client.mjs +332 -0
  60. package/server/services/remote-relay-dispatcher.mjs +1500 -0
  61. package/server/services/remote-relay-inbound.mjs +187 -0
  62. package/server/services/remote-relay-pairing.mjs +189 -0
  63. package/server/services/remote-relay-registry-service.mjs +581 -0
  64. package/server/services/session-worker-launch-service.mjs +131 -12
  65. package/shared/remote-relay-contract.mjs +539 -0
  66. package/shared/remote-relay-mentions.mjs +129 -0
  67. package/shared/remote-relay-tool-core.mjs +314 -0
  68. package/shared/worker-runtime/api-client.mjs +112 -22
@@ -0,0 +1,126 @@
1
+ // The browser's copy of this relay's remote-relay list (Settings → Relays and
2
+ // the composer's @mention popup read it). Filled lazily from
3
+ // GET /api/remote-relays and replaced wholesale by the relay's
4
+ // `remote_relays_updated` socket event; never carries tokens.
5
+
6
+ import { IS_SHARED_VIEW } from './store.js';
7
+ import { loadRemoteRelays } from './api-client.js';
8
+
9
+ // A relay without the feature answers 404; do not ask again on every "@".
10
+ const FAILED_LOAD_RETRY_MS = 60_000;
11
+ const KNOWN_STATUSES = new Set(['online', 'offline', 'unauthorized', 'error', 'unknown']);
12
+
13
+ /** A remote's health for its dot: online, offline, unauthorized, error or unknown. */
14
+ export function remoteRelayStatus(relay) {
15
+ const status = String(relay?.lastStatus || '').trim().toLowerCase();
16
+ return KNOWN_STATUSES.has(status) ? status : 'unknown';
17
+ }
18
+
19
+ let snapshot = null; // { relays: [], self: {} | null } once known
20
+ let loadPromise = null;
21
+ let lastFailedLoadAt = 0;
22
+ const listeners = new Set();
23
+
24
+ function normalizeRelay(raw) {
25
+ if (!raw || typeof raw !== 'object') return null;
26
+ const id = String(raw.id || '').trim();
27
+ if (!id) return null;
28
+ return { ...raw, id };
29
+ }
30
+
31
+ function normalizeSnapshot(payload) {
32
+ const relays = Array.isArray(payload?.relays)
33
+ ? payload.relays.map(normalizeRelay).filter(Boolean)
34
+ : [];
35
+ const self = payload?.self && typeof payload.self === 'object' ? { ...payload.self } : null;
36
+ return { relays, self };
37
+ }
38
+
39
+ function notify() {
40
+ for (const listener of listeners) {
41
+ try {
42
+ listener(snapshot);
43
+ } catch (error) {
44
+ console.warn('[remote-relays] listener failed', error?.message || error);
45
+ }
46
+ }
47
+ }
48
+
49
+ /** The last known `{ relays, self }`, or null before the first load. */
50
+ export function getRemoteRelaysSnapshot() {
51
+ return snapshot;
52
+ }
53
+
54
+ export function getRemoteRelays() {
55
+ return snapshot?.relays || [];
56
+ }
57
+
58
+ /**
59
+ * Replaces the list (socket event, GET response). A payload without a
60
+ * `relays` array is ignored rather than wiping what is known; `self` survives
61
+ * a payload that omits it.
62
+ */
63
+ export function setRemoteRelaysSnapshot(payload) {
64
+ if (!payload || typeof payload !== 'object' || !Array.isArray(payload.relays)) return snapshot;
65
+ const next = normalizeSnapshot(payload);
66
+ if (!next.self && snapshot?.self) next.self = snapshot.self;
67
+ snapshot = next;
68
+ lastFailedLoadAt = 0;
69
+ notify();
70
+ return snapshot;
71
+ }
72
+
73
+ /** Applies one relay a PATCH/check answered with, without a full reload. */
74
+ export function upsertRemoteRelay(relay) {
75
+ const normalized = normalizeRelay(relay);
76
+ if (!normalized || !snapshot) return snapshot;
77
+ const relays = snapshot.relays.slice();
78
+ const index = relays.findIndex((entry) => entry.id === normalized.id);
79
+ if (index === -1) relays.push(normalized);
80
+ else relays[index] = normalized;
81
+ snapshot = { ...snapshot, relays };
82
+ notify();
83
+ return snapshot;
84
+ }
85
+
86
+ export function removeRemoteRelayFromSnapshot(id) {
87
+ const key = String(id || '').trim();
88
+ if (!snapshot || !key) return snapshot;
89
+ snapshot = { ...snapshot, relays: snapshot.relays.filter((entry) => entry.id !== key) };
90
+ notify();
91
+ return snapshot;
92
+ }
93
+
94
+ /** Fetches the list now. Resolves to the snapshot (null when unavailable). */
95
+ export async function refreshRemoteRelays() {
96
+ if (IS_SHARED_VIEW) return snapshot;
97
+ const payload = await loadRemoteRelays();
98
+ if (payload && Array.isArray(payload.relays)) return setRemoteRelaysSnapshot(payload);
99
+ lastFailedLoadAt = Date.now();
100
+ return snapshot;
101
+ }
102
+
103
+ /** Loads once, lazily (first "@" in the composer); later calls reuse the cache. */
104
+ export function ensureRemoteRelaysLoaded() {
105
+ if (snapshot) return Promise.resolve(snapshot);
106
+ if (IS_SHARED_VIEW) return Promise.resolve(null);
107
+ if (loadPromise) return loadPromise;
108
+ if (lastFailedLoadAt && Date.now() - lastFailedLoadAt < FAILED_LOAD_RETRY_MS) return Promise.resolve(null);
109
+ loadPromise = refreshRemoteRelays().finally(() => {
110
+ loadPromise = null;
111
+ });
112
+ return loadPromise;
113
+ }
114
+
115
+ export function subscribeRemoteRelays(listener) {
116
+ if (typeof listener !== 'function') return () => {};
117
+ listeners.add(listener);
118
+ return () => listeners.delete(listener);
119
+ }
120
+
121
+ /** Test hook: forget everything. */
122
+ export function resetRemoteRelaysStoreForTests() {
123
+ snapshot = null;
124
+ loadPromise = null;
125
+ lastFailedLoadAt = 0;
126
+ }
@@ -43,6 +43,7 @@ import { refreshClaudeAuthSection } from './claude-auth-ui.js';
43
43
  import { refreshGrokAuthSection } from './grok-auth-ui.js';
44
44
  import { refreshCliInstallSections } from './cli-install-ui.js';
45
45
  import { refreshUpdateSection } from './update-ui.js';
46
+ import { refreshRemoteRelaysSection } from './remote-relays-settings.js';
46
47
  import {
47
48
  getPreviews,
48
49
  renderPreviewRowsInto,
@@ -1632,8 +1633,8 @@ subscribePreviews(() => {
1632
1633
  renderPreviewsSection();
1633
1634
  });
1634
1635
 
1635
- // `tab` / `providerTab` are optional deep links (e.g. openSettingsModal('providers', 'claude'));
1636
- // omitting them restores the last tab the user was on.
1636
+ // `tab` / `providerTab` are optional deep links (e.g. openSettingsModal('providers', 'claude')
1637
+ // or openSettingsModal('relays')); omitting them restores the last tab the user was on.
1637
1638
  export function openSettingsModal(tab, providerTab) {
1638
1639
  closeChatActionsMenu();
1639
1640
  const modal = document.getElementById('settings-modal');
@@ -1676,6 +1677,7 @@ export function openSettingsModal(tab, providerTab) {
1676
1677
  void refreshBackgroundTaskTimeoutSetting();
1677
1678
  void refreshPushSettingsSection();
1678
1679
  void refreshPreviewsSection();
1680
+ void refreshRemoteRelaysSection();
1679
1681
  void refreshFeatureFlagsSection();
1680
1682
  void refreshUpdateSection();
1681
1683
  modal?.classList.add('visible');
@@ -1,7 +1,7 @@
1
1
  const SETTINGS_TAB_STORAGE_KEY = 'copilot_settings_tab';
2
2
  const SETTINGS_PROVIDER_TAB_STORAGE_KEY = 'copilot_settings_provider_tab';
3
3
 
4
- const SETTINGS_TABS = ['general', 'providers', 'previews', 'notifications', 'features'];
4
+ const SETTINGS_TABS = ['general', 'providers', 'relays', 'previews', 'notifications', 'features'];
5
5
  // Copilot leads: it is the default provider, and `SETTINGS_PROVIDER_TABS[0]`
6
6
  // is also the sub-tab a first-time visitor lands on.
7
7
  const SETTINGS_PROVIDER_TABS = ['copilot', 'openai', 'claude', 'grok', 'cursor'];
@@ -42,10 +42,13 @@ import {
42
42
  import { upsertRelayBoard, loadRelayBoards, renderRelayBoards } from './relay-board-view.js';
43
43
  import { setConversationBackgroundTasks } from './background-tasks-view.mjs';
44
44
  import { setPreviews } from './preview-cards.mjs';
45
+ import { setRemoteRelaysSnapshot } from './remote-relays-store.mjs';
46
+ import { REMOTE_RELAY_SOCKET_EVENT } from './remote-relay-shared.mjs';
45
47
  import { applyClaudeAuthState } from './claude-auth-ui.js';
46
48
  import { applyGrokAuthState } from './grok-auth-ui.js';
47
49
  import { applyCliInstallState } from './cli-install-ui.js';
48
50
  import { renderUpdateSection } from './update-ui.js';
51
+ import { applyHostSuspendState, applyRelayShutdownState } from './host-suspend-ui.js';
49
52
  import {
50
53
  showThinking,
51
54
  removeThinking,
@@ -388,6 +391,14 @@ export async function connectSocket(overrideDeps) {
388
391
  setUpdateState(payload || null);
389
392
  renderUpdateSection();
390
393
  });
394
+ // Pending host suspend / relay restart: banner + menu labels on every
395
+ // device. Also sent on connect, so a reconnecting client catches up.
396
+ socket.on('host_suspend_state', (payload) => {
397
+ applyHostSuspendState(payload || null);
398
+ });
399
+ socket.on('relay_shutdown_state', (payload) => {
400
+ applyRelayShutdownState(payload || null);
401
+ });
391
402
  socket.on('grok_settings_updated', (payload) => {
392
403
  deps?.applyGrokSettingsState?.(payload || {});
393
404
  if (Number(payload?.reconciliation?.updatedUnstartedConversations || 0) > 0) {
@@ -422,7 +433,8 @@ export async function connectSocket(overrideDeps) {
422
433
  socket.on('user_message', ({ conversationId, messageId, senderClientId, message }) => {
423
434
  const normalizedMessage = {
424
435
  ...(message && typeof message === 'object' ? message : {}),
425
- text: stripRelayPromptContext(message?.text, message?.mode),
436
+ // `origin`: a prompt another relay's agent sent; its header line hides.
437
+ text: stripRelayPromptContext(message?.text, message?.mode, { origin: message?.origin }),
426
438
  };
427
439
  if (senderClientId && senderClientId === CLIENT_ID) {
428
440
  pendingUserMessageIds.delete(messageId);
@@ -495,6 +507,11 @@ export async function connectSocket(overrideDeps) {
495
507
  socket.on('previews', ({ previews }) => {
496
508
  setPreviews(previews);
497
509
  });
510
+ // REPLACE semantics too: the relay ships the whole remote-relay list (no
511
+ // tokens) whenever a remote is added, removed, renamed or changes status.
512
+ socket.on(REMOTE_RELAY_SOCKET_EVENT, (payload) => {
513
+ setRemoteRelaysSnapshot(payload || null);
514
+ });
498
515
  socket.on('relay_activity', ({ conversationId, messageId, text, subagentRunId, metadata }) => {
499
516
  if (!messageId || !text) return;
500
517
  const entry = {
@@ -0,0 +1,260 @@
1
+ // Follow modes for the live thinking bubble: 💭 thoughts, 📃 streamed answer,
2
+ // đŸ› ī¸ tool list. While a mode is armed the message pane keeps the tracked
3
+ // section's bottom edge pinned just above the composer as it grows, so the
4
+ // user can read a long reasoning stream without the answer/tool rows below
5
+ // pushing it away. Manual scrolling (wheel, touch, scroll keys, scrollbar)
6
+ // disarms the mode; typing in the composer does not. The mode is remembered
7
+ // per conversation for the life of the page.
8
+
9
+ import { isChatInteractionHeld } from './selection-guard.mjs';
10
+
11
+ export const THINKING_FOLLOW_MODES = Object.freeze(['thoughts', 'stream', 'tools']);
12
+ export const THINKING_FOLLOW_BOTTOM_GAP_PX = 16;
13
+
14
+ const FOLLOW_BUTTONS = Object.freeze([
15
+ { mode: 'thoughts', icon: '💭', title: 'Follow the thoughts' },
16
+ { mode: 'stream', icon: '📃', title: 'Follow the streamed answer' },
17
+ { mode: 'tools', icon: 'đŸ› ī¸', title: 'Follow the tool list' },
18
+ ]);
19
+
20
+ const TARGET_SELECTORS = Object.freeze({
21
+ thoughts: '#thinking-thoughts > .thinking-thoughts-list',
22
+ stream: '#thinking-stream',
23
+ tools: '#thinking-activity',
24
+ });
25
+
26
+ // Keys a user presses to scroll the pane. Editable targets are excluded by the
27
+ // guard, so these only count when the pane itself (or nothing) has focus.
28
+ const SCROLL_KEYS = new Set(['PageUp', 'PageDown', 'Home', 'End', 'ArrowUp', 'ArrowDown', ' ', 'Spacebar']);
29
+ // A scroll event this close after our own scrollTop write is ours.
30
+ const PROGRAMMATIC_SCROLL_WINDOW_MS = 150;
31
+ const SCROLL_DRIFT_TOLERANCE_PX = 2;
32
+
33
+ const followModeByConversation = new Map();
34
+ let currentConversationIdResolver = () => '';
35
+ let onModeChanged = () => {};
36
+ let programmaticScrollTop = null;
37
+ let programmaticScrollAt = 0;
38
+ let applyScheduled = false;
39
+ let resizeObserver = null;
40
+ let mutationObserver = null;
41
+ let observedBubble = null;
42
+ let guardsInstalledOn = null;
43
+
44
+ function normalizeMode(mode) {
45
+ const value = String(mode || '').trim();
46
+ return THINKING_FOLLOW_MODES.includes(value) ? value : null;
47
+ }
48
+
49
+ function conversationKey(conversationId) {
50
+ const explicit = String(conversationId || '').trim();
51
+ if (explicit) return explicit;
52
+ try { return String(currentConversationIdResolver() || '').trim(); } catch { return ''; }
53
+ }
54
+
55
+ export function getThinkingFollowMode(conversationId = null) {
56
+ const key = conversationKey(conversationId);
57
+ return key ? followModeByConversation.get(key) || null : null;
58
+ }
59
+
60
+ export function setThinkingFollowMode(conversationId, mode) {
61
+ const key = conversationKey(conversationId);
62
+ if (!key) return null;
63
+ const next = normalizeMode(mode);
64
+ const previous = followModeByConversation.get(key) || null;
65
+ if (next) followModeByConversation.set(key, next);
66
+ else followModeByConversation.delete(key);
67
+ if (next !== previous) {
68
+ try { onModeChanged({ conversationId: key, mode: next, previous }); } catch {}
69
+ }
70
+ return next;
71
+ }
72
+
73
+ /** Pressing the armed button disarms; pressing another switches. */
74
+ export function toggleThinkingFollowMode(conversationId, mode) {
75
+ const current = getThinkingFollowMode(conversationId);
76
+ const next = normalizeMode(mode);
77
+ return setThinkingFollowMode(conversationId, current === next ? null : next);
78
+ }
79
+
80
+ export function disarmThinkingFollow(conversationId = null) {
81
+ return setThinkingFollowMode(conversationId, null);
82
+ }
83
+
84
+ /** Markup for the three buttons; `mode` marks the armed one. */
85
+ export function renderThinkingFollowButtonsHtml(mode = null) {
86
+ const active = normalizeMode(mode);
87
+ const buttons = FOLLOW_BUTTONS.map(({ mode: value, icon, title }) => {
88
+ const pressed = active === value;
89
+ return `<button type="button" class="thinking-follow-btn${pressed ? ' active' : ''}" data-action="follow-thinking" data-follow="${value}" aria-pressed="${pressed ? 'true' : 'false'}" title="${title}" aria-label="${title}">${icon}</button>`;
90
+ }).join('');
91
+ return `<div class="thinking-follow-group" role="group" aria-label="Follow the live reply">${buttons}</div>`;
92
+ }
93
+
94
+ /** Update aria-pressed/active on an existing group without rebuilding it. */
95
+ export function syncThinkingFollowButtons(root = document, mode = getThinkingFollowMode()) {
96
+ const active = normalizeMode(mode);
97
+ const buttons = root?.querySelectorAll?.('.thinking-follow-btn[data-follow]') || [];
98
+ for (const btn of buttons) {
99
+ const pressed = btn.dataset.follow === active;
100
+ btn.classList.toggle('active', pressed);
101
+ btn.setAttribute('aria-pressed', pressed ? 'true' : 'false');
102
+ }
103
+ }
104
+
105
+ function resolveTarget(mode) {
106
+ const selector = TARGET_SELECTORS[normalizeMode(mode)];
107
+ if (!selector) return null;
108
+ const el = document.querySelector(selector);
109
+ if (!el) return null;
110
+ if (el.hidden || el.closest?.('[hidden]')) return null;
111
+ // A collapsed <details> or an empty list has nothing to follow yet.
112
+ const details = el.closest?.('details');
113
+ if (details && !details.open) return null;
114
+ if (!el.childElementCount && !String(el.textContent || '').trim()) return null;
115
+ return el;
116
+ }
117
+
118
+ /**
119
+ * scrollTop that puts `target`'s bottom edge `gapPx` above the scroller's
120
+ * bottom edge. Pure for tests: takes rects/metrics rather than elements.
121
+ */
122
+ export function computeFollowScrollTop({
123
+ scrollTop,
124
+ clientHeight,
125
+ scrollHeight,
126
+ scrollerTop,
127
+ targetBottom,
128
+ gapPx = THINKING_FOLLOW_BOTTOM_GAP_PX,
129
+ }) {
130
+ const desiredBottomInScroller = clientHeight - gapPx;
131
+ const targetBottomInScroller = targetBottom - scrollerTop;
132
+ const next = scrollTop + (targetBottomInScroller - desiredBottomInScroller);
133
+ const max = Math.max(0, scrollHeight - clientHeight);
134
+ return Math.max(0, Math.min(max, Math.round(next)));
135
+ }
136
+
137
+ export function applyThinkingFollow({ force = false } = {}) {
138
+ const mode = getThinkingFollowMode();
139
+ if (!mode) return false;
140
+ const scroller = document.getElementById('messages');
141
+ const target = resolveTarget(mode);
142
+ if (!scroller || !target) return false;
143
+ if (!force && isChatInteractionHeld()) return false;
144
+ const scrollerRect = scroller.getBoundingClientRect();
145
+ const targetRect = target.getBoundingClientRect();
146
+ const next = computeFollowScrollTop({
147
+ scrollTop: scroller.scrollTop,
148
+ clientHeight: scroller.clientHeight,
149
+ scrollHeight: scroller.scrollHeight,
150
+ scrollerTop: scrollerRect.top,
151
+ targetBottom: targetRect.bottom,
152
+ });
153
+ if (Math.abs(next - scroller.scrollTop) <= SCROLL_DRIFT_TOLERANCE_PX) {
154
+ programmaticScrollTop = scroller.scrollTop;
155
+ programmaticScrollAt = Date.now();
156
+ return true;
157
+ }
158
+ programmaticScrollTop = next;
159
+ programmaticScrollAt = Date.now();
160
+ scroller.scrollTop = next;
161
+ return true;
162
+ }
163
+
164
+ export function scheduleThinkingFollow() {
165
+ if (!getThinkingFollowMode()) return;
166
+ if (applyScheduled) return;
167
+ applyScheduled = true;
168
+ const raf = typeof requestAnimationFrame === 'function' ? requestAnimationFrame : (fn) => setTimeout(fn, 16);
169
+ raf(() => {
170
+ applyScheduled = false;
171
+ applyThinkingFollow();
172
+ });
173
+ }
174
+
175
+ /** Watch the live bubble so any growth re-pins the tracked section. */
176
+ export function observeThinkingBubble(bubbleEl) {
177
+ if (!bubbleEl || bubbleEl === observedBubble) return;
178
+ unobserveThinkingBubble();
179
+ observedBubble = bubbleEl;
180
+ // Guards go on lazily with the first live bubble, after the pane's own
181
+ // scroll listeners (history paging) are in place.
182
+ installThinkingFollowScrollGuards();
183
+ if (typeof MutationObserver === 'function') {
184
+ mutationObserver = new MutationObserver(() => scheduleThinkingFollow());
185
+ mutationObserver.observe(bubbleEl, { childList: true, subtree: true, characterData: true, attributes: true, attributeFilter: ['open', 'hidden', 'class'] });
186
+ }
187
+ if (typeof ResizeObserver === 'function') {
188
+ resizeObserver = new ResizeObserver(() => scheduleThinkingFollow());
189
+ resizeObserver.observe(bubbleEl);
190
+ const scroller = document.getElementById('messages');
191
+ if (scroller) resizeObserver.observe(scroller);
192
+ }
193
+ }
194
+
195
+ export function unobserveThinkingBubble() {
196
+ try { mutationObserver?.disconnect(); } catch {}
197
+ try { resizeObserver?.disconnect(); } catch {}
198
+ mutationObserver = null;
199
+ resizeObserver = null;
200
+ observedBubble = null;
201
+ }
202
+
203
+ function isEditableTarget(target) {
204
+ const el = target instanceof Element ? target : null;
205
+ if (!el) return false;
206
+ if (el.closest('input, textarea, select, [contenteditable=""], [contenteditable="true"]')) return true;
207
+ return false;
208
+ }
209
+
210
+ function disarmFromUserScroll() {
211
+ if (!getThinkingFollowMode()) return;
212
+ disarmThinkingFollow();
213
+ syncThinkingFollowButtons(document, null);
214
+ }
215
+
216
+ /**
217
+ * Manual scrolling in the pane disarms the mode. Installed once per scroller;
218
+ * the composer is outside the pane so typing never trips it.
219
+ */
220
+ export function installThinkingFollowScrollGuards(scroller = document.getElementById('messages')) {
221
+ if (!scroller || guardsInstalledOn === scroller) return;
222
+ guardsInstalledOn = scroller;
223
+ scroller.addEventListener('wheel', () => disarmFromUserScroll(), { passive: true });
224
+ scroller.addEventListener('touchmove', () => disarmFromUserScroll(), { passive: true });
225
+ // A pointer press on the scrollbar gutter (outside the content box).
226
+ scroller.addEventListener('pointerdown', (event) => {
227
+ if (event.pointerType && event.pointerType !== 'mouse') return;
228
+ if (event.offsetX > scroller.clientWidth) disarmFromUserScroll();
229
+ });
230
+ scroller.addEventListener('scroll', () => {
231
+ if (!getThinkingFollowMode()) return;
232
+ const recent = Date.now() - programmaticScrollAt <= PROGRAMMATIC_SCROLL_WINDOW_MS;
233
+ if (recent && programmaticScrollTop !== null && Math.abs(scroller.scrollTop - programmaticScrollTop) <= SCROLL_DRIFT_TOLERANCE_PX) return;
234
+ if (recent) return;
235
+ // Layout shifts move scrollTop too; only a move away from the pinned
236
+ // position counts as the user taking over.
237
+ if (programmaticScrollTop !== null && Math.abs(scroller.scrollTop - programmaticScrollTop) <= SCROLL_DRIFT_TOLERANCE_PX) return;
238
+ disarmFromUserScroll();
239
+ }, { passive: true });
240
+ document.addEventListener('keydown', (event) => {
241
+ if (!getThinkingFollowMode()) return;
242
+ if (!SCROLL_KEYS.has(event.key)) return;
243
+ if (isEditableTarget(event.target)) return;
244
+ disarmFromUserScroll();
245
+ });
246
+ }
247
+
248
+ export function initThinkingFollow({ getCurrentConversationId, onChange } = {}) {
249
+ if (typeof getCurrentConversationId === 'function') currentConversationIdResolver = getCurrentConversationId;
250
+ if (typeof onChange === 'function') onModeChanged = onChange;
251
+ }
252
+
253
+ export function __resetThinkingFollowForTests() {
254
+ followModeByConversation.clear();
255
+ programmaticScrollTop = null;
256
+ programmaticScrollAt = 0;
257
+ applyScheduled = false;
258
+ unobserveThinkingBubble();
259
+ guardsInstalledOn = null;
260
+ }
@@ -31,6 +31,10 @@ export function prefixToolActivityEmoji(text) {
31
31
  if (/^Tool \((create_file|write)\)/i.test(value)) return `🆕 ${value}`;
32
32
  if (/^Tool \((bash|shell|terminal)\)/i.test(value)) return `🔧 ${value}`;
33
33
  if (/^Tool \((sql|sqlite)\)/i.test(value)) return `đŸ—„ī¸ ${value}`;
34
+ // Work on another OAR relay. Claude's raw name is mcp__relay__remote_relay and
35
+ // the Copilot CLI names MCP tools <server>-<tool> (oar-remote_relay); the
36
+ // activity normalisers strip both, but a line that kept one matches too.
37
+ if (/^Tool \((?:mcp__[a-z0-9_-]+__|[a-z0-9_]+-)?remote_relay\)/i.test(value)) return `đŸ›°ī¸ ${value}`;
34
38
  if (/^Tool \(/i.test(value)) return `đŸ› ī¸ ${value}`;
35
39
  return `â„šī¸ ${value}`;
36
40
  }