@oar-sh/oar 0.9.2 → 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 +336 -0
- package/README.md +455 -423
- package/bin/oar.js +25 -10
- package/package.json +11 -5
- package/server/README.md +44 -4
- 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 +1073 -69
- package/server/claude-worker/claude-session-worker.mjs +45 -4
- package/server/claude-worker/claude-turn-publisher.mjs +81 -20
- package/server/claude-worker/claude-worker-link-wiring.mjs +9 -0
- package/server/claude-worker/sdk-message-normalizer.mjs +7 -0
- package/server/copilot-worker/copilot-ask-user-tool.mjs +89 -0
- package/server/copilot-worker/copilot-continuation-signals.mjs +55 -0
- package/server/copilot-worker/copilot-question-bridge.mjs +34 -12
- package/server/copilot-worker/copilot-remote-relay-tool.mjs +59 -0
- package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +113 -14
- package/server/copilot-worker/copilot-sdk-session-process.mjs +2349 -498
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +82 -20
- 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 +28 -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/0004-steer-settle-markers.mjs +119 -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 +125 -2
- package/server/public/app/ask-user-view.js +102 -13
- package/server/public/app/attachments-view.js +37 -2
- package/server/public/app/background-task-timeout-options.mjs +9 -0
- package/server/public/app/background-tasks-view.mjs +33 -18
- package/server/public/app/bootstrap.js +55 -14
- package/server/public/app/composer-control-state.mjs +52 -34
- package/server/public/app/conversation-deep-link.mjs +42 -0
- package/server/public/app/conversation-draft-sync.mjs +187 -0
- package/server/public/app/conversation-list-filter.mjs +86 -0
- package/server/public/app/conversation-provider-indicator.mjs +8 -0
- package/server/public/app/conversation-view.js +1016 -200
- package/server/public/app/emoji-view.js +2 -0
- package/server/public/app/font-scaling.js +4 -37
- package/server/public/app/host-suspend-ui.js +272 -0
- package/server/public/app/journal-view.js +149 -6
- package/server/public/app/mention-autocomplete.mjs +298 -0
- package/server/public/app/model-selector-options.mjs +1 -1
- package/server/public/app/push-settings.js +1 -0
- package/server/public/app/pwa-install.js +8 -3
- package/server/public/app/question-multi-select.mjs +52 -0
- package/server/public/app/relay-prompt-sanitizer.mjs +61 -3
- 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 +12 -8
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/socket-handlers.js +37 -1
- package/server/public/app/steered-turn-merge.mjs +100 -0
- package/server/public/app/store.js +126 -15
- package/server/public/app/sync-outbox.mjs +16 -8
- package/server/public/app/thinking-follow.js +260 -0
- package/server/public/app/tool-activity-emoji.mjs +40 -0
- package/server/public/app/transcript-viewport-anchor.mjs +210 -0
- package/server/public/index.html +463 -17
- package/server/public/sw.js +7 -0
- package/server/relay-tools.md +7 -1
- package/server/repositories/message-repository.mjs +68 -18
- package/server/repositories/question-repository.mjs +18 -0
- package/server/repositories/remote-relay-repository.mjs +75 -0
- package/server/repositories/session-repository.mjs +13 -32
- package/server/routes/ask-user-routes.mjs +42 -2
- package/server/routes/messages-routes.mjs +572 -56
- package/server/routes/remote-relay-routes.mjs +133 -0
- package/server/routes/sessions-routes.mjs +378 -84
- package/server/server-runtime.mjs +303 -25
- 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/live-turn-picker.mjs +47 -0
- package/server/services/push-dispatch-service.mjs +37 -4
- package/server/services/relay-prompt-sanitizer.mjs +17 -2
- package/server/services/relay-question-context.mjs +46 -0
- 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/sdk-session-import-service.mjs +49 -7
- package/server/services/session-worker-launch-service.mjs +131 -12
- package/server/services/session-worker-registry-service.mjs +75 -0
- package/server/services/session-worker-supervisor-service.mjs +7 -1
- package/server/services/session-worker-websocket-service.mjs +23 -1
- package/server/services/steer-resend.mjs +45 -0
- package/shared/background-task-timeout.mjs +12 -4
- package/shared/control-poller.mjs +4 -1
- package/shared/question-multi-select.mjs +16 -0
- package/shared/relay-stop-failure.mjs +15 -0
- 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/slash-command-guard.mjs +76 -0
- package/shared/steer-note.mjs +66 -0
- package/shared/steer-settle-failure.mjs +89 -0
- package/shared/steer-settle-markers.mjs +18 -0
- package/shared/worker-crash-guard.mjs +40 -25
- package/shared/worker-runtime/api-client.mjs +112 -22
- package/shared/worker-runtime/heartbeat.mjs +55 -1
- package/shared/worker-runtime/runner-link-wiring.mjs +44 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +119 -8
- package/server/claude-worker/claude-session-test-harness.mjs +0 -250
- package/server/claude-worker/fixtures/workflow-run-record.json +0 -1
- package/server/copilot-worker/copilot-sdk-test-harness.mjs +0 -382
- package/server/copilot-worker/fixtures/abort-turn.json +0 -588
- package/server/copilot-worker/fixtures/ask-user-turn.json +0 -1154
- package/server/copilot-worker/fixtures/background-timer-continuation.json +0 -445
- package/server/copilot-worker/fixtures/background-timer-turn.json +0 -497
- package/server/copilot-worker/fixtures/happy-turn.json +0 -566
- package/server/copilot-worker/fixtures/quota-turn.json +0 -647
- package/server/copilot-worker/fixtures/reasoning-turn.json +0 -625
- package/server/copilot-worker/fixtures/resume-turn.json +0 -700
- package/server/copilot-worker/fixtures/subagent-turn.json +0 -1378
- package/server/copilot-worker/fixtures/tool-permission-turn.json +0 -1293
- package/server/routes/messages-routes-test-harness.mjs +0 -111
- package/server/services/fixtures/claude-auth-stub.cmd +0 -1
- package/server/services/fixtures/claude-auth-stub.mjs +0 -76
- package/server/services/fixtures/claude-auth-stub.sh +0 -4
- package/server/services/fixtures/cli-install-stub.cmd +0 -1
- package/server/services/fixtures/cli-install-stub.mjs +0 -98
- package/server/services/fixtures/cli-install-stub.sh +0 -4
- package/server/services/fixtures/grok-stub.cmd +0 -1
- package/server/services/fixtures/grok-stub.mjs +0 -89
- package/server/services/fixtures/grok-stub.sh +0 -4
- package/shared/fixtures/copilot-catalog-raw-2026-09-07.json +0 -2363
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Leading emoji for a tool-activity line ("Tool (PowerShell): …" → "🪓 Tool
|
|
2
|
+
// (PowerShell): …"), shared by the transcript's activity rows and the
|
|
3
|
+
// composer's background-tasks panel so a tool reads the same everywhere.
|
|
4
|
+
// Text that already starts with an emoji is returned unchanged.
|
|
5
|
+
export function prefixToolActivityEmoji(text) {
|
|
6
|
+
const value = String(text || '').trim();
|
|
7
|
+
if (!value) return '';
|
|
8
|
+
if (/^[\u{1F300}-\u{1FAFF}☀-➿]/u.test(value)) return value;
|
|
9
|
+
if (value.startsWith('● ')) return `🔄 ${value.slice(2).trim()}`;
|
|
10
|
+
if (/^Model selected:/i.test(value)) return `🧠 ${value}`;
|
|
11
|
+
if (/^Search \((glob|grep)\)/i.test(value)) return `🔍 ${value}`;
|
|
12
|
+
if (/^Tool \(ask_user\)/i.test(value)) return `❓ ${value}`;
|
|
13
|
+
if (/^Tool \(view\)/i.test(value)) return `👀 ${value}`;
|
|
14
|
+
if (/^Tool \(apply_patch\)/i.test(value)) return `🪡 ${value}`;
|
|
15
|
+
if (/^Tool \(powershell\)/i.test(value)) return `🪓 ${value}`;
|
|
16
|
+
if (/^Tool \(edit\)/i.test(value)) return `📝 ${value}`;
|
|
17
|
+
if (/^Tool \(read_file\)/i.test(value)) return `📄 ${value}`;
|
|
18
|
+
if (/^Tool \((grep_search|file_search)\)/i.test(value)) return `🔎 ${value}`;
|
|
19
|
+
if (/^Tool \(semantic_search\)/i.test(value)) return `🧭 ${value}`;
|
|
20
|
+
if (/^Tool \(vscode_listCodeUsages\)/i.test(value)) return `🔗 ${value}`;
|
|
21
|
+
if (/^Tool \(vscode_renameSymbol\)/i.test(value)) return `✏️ ${value}`;
|
|
22
|
+
if (/^Tool \(list_dir\)/i.test(value)) return `📂 ${value}`;
|
|
23
|
+
if (/^Tool \(create_directory\)/i.test(value)) return `📁 ${value}`;
|
|
24
|
+
if (/^Tool \((delete|remove)\)/i.test(value)) return `🗑️ ${value}`;
|
|
25
|
+
if (/^Tool \(execution_subagent\)/i.test(value)) return `🚀 ${value}`;
|
|
26
|
+
if (/^Tool \(get_errors\)/i.test(value)) return `🚨 ${value}`;
|
|
27
|
+
if (/^Tool \(debug_[^)]+\)/i.test(value)) return `🐞 ${value}`;
|
|
28
|
+
if (/^Tool \(fetch_webpage\)/i.test(value)) return `🌐 ${value}`;
|
|
29
|
+
if (/^Tool \(github_[^)]+\)/i.test(value)) return `🐙 ${value}`;
|
|
30
|
+
if (/^Tool \(run_in_terminal\)/i.test(value)) return `🖥️ ${value}`;
|
|
31
|
+
if (/^Tool \((create_file|write)\)/i.test(value)) return `🆕 ${value}`;
|
|
32
|
+
if (/^Tool \((bash|shell|terminal)\)/i.test(value)) return `🔧 ${value}`;
|
|
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}`;
|
|
38
|
+
if (/^Tool \(/i.test(value)) return `🛠️ ${value}`;
|
|
39
|
+
return `ℹ️ ${value}`;
|
|
40
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// Keeps the transcript's reading position through layout changes that
|
|
2
|
+
// reflow the rows: phone rotation, the on-screen keyboard, a desktop window
|
|
3
|
+
// resize, a font-scale change. The browser only preserves scrollTop as a
|
|
4
|
+
// pixel count (and its own scroll anchoring pins the top-most row), so a
|
|
5
|
+
// reader sitting at the end of a portrait transcript lands screens above
|
|
6
|
+
// the end once the wider landscape rows shrink the content. A position is
|
|
7
|
+
// therefore described by content, not pixels: "at the bottom", or "this
|
|
8
|
+
// message is the top-most visible one, with this fraction of it scrolled
|
|
9
|
+
// past" — a fraction, because the rows themselves change height when the
|
|
10
|
+
// width changes, and a pixel offset would hide a whole shrunken row.
|
|
11
|
+
|
|
12
|
+
const BOTTOM_EPSILON_PX = 1;
|
|
13
|
+
|
|
14
|
+
/** Same rule the composer's follow-live logic uses: 8% of the viewport,
|
|
15
|
+
* clamped to 12–48 px. */
|
|
16
|
+
export function resolveNearBottomThresholdPx(clientHeight) {
|
|
17
|
+
const viewportHeight = Number(clientHeight || 0);
|
|
18
|
+
if (!Number.isFinite(viewportHeight) || viewportHeight <= 0) return 0;
|
|
19
|
+
return Math.min(48, Math.max(12, Math.floor(viewportHeight * 0.08)));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function distanceFromBottom(el) {
|
|
23
|
+
return Math.max(0, Number(el.scrollHeight || 0) - Number(el.clientHeight || 0) - Number(el.scrollTop || 0));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function transcriptRows(el) {
|
|
27
|
+
return Array.from(el.querySelectorAll('.msg[data-message-id]'));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Describes where the reader is. `atBottom` uses the near-bottom threshold
|
|
32
|
+
* rather than an exact pixel match: a reader a few pixels short of the end
|
|
33
|
+
* still means "the end", and a rotation must not strand them above it.
|
|
34
|
+
* Returns null for an empty transcript.
|
|
35
|
+
*/
|
|
36
|
+
export function captureTranscriptAnchor(el, { nearBottomThresholdPx = null } = {}) {
|
|
37
|
+
if (!el) return null;
|
|
38
|
+
const rows = transcriptRows(el);
|
|
39
|
+
if (!rows.length) return null;
|
|
40
|
+
const threshold = nearBottomThresholdPx !== null && Number.isFinite(Number(nearBottomThresholdPx))
|
|
41
|
+
? Math.max(0, Number(nearBottomThresholdPx))
|
|
42
|
+
: Math.max(BOTTOM_EPSILON_PX, resolveNearBottomThresholdPx(el.clientHeight));
|
|
43
|
+
const atBottom = distanceFromBottom(el) <= threshold;
|
|
44
|
+
const containerRect = el.getBoundingClientRect();
|
|
45
|
+
let anchorRow = null;
|
|
46
|
+
let offsetTop = 0;
|
|
47
|
+
let rowHeight = 0;
|
|
48
|
+
for (const row of rows) {
|
|
49
|
+
const rect = row.getBoundingClientRect();
|
|
50
|
+
if (rect.bottom <= containerRect.top + 1) continue;
|
|
51
|
+
if (rect.top >= containerRect.bottom) break;
|
|
52
|
+
anchorRow = row;
|
|
53
|
+
offsetTop = rect.top - containerRect.top;
|
|
54
|
+
rowHeight = Math.max(0, rect.bottom - rect.top);
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
const maxScroll = Math.max(1, Number(el.scrollHeight || 0) - Number(el.clientHeight || 0));
|
|
58
|
+
return {
|
|
59
|
+
atBottom,
|
|
60
|
+
messageId: String(anchorRow?.dataset?.messageId || '').trim() || null,
|
|
61
|
+
// Negative while the row's top is scrolled past the edge; scaled to the
|
|
62
|
+
// row's height so the same share of it stays hidden after a reflow.
|
|
63
|
+
offsetFraction: rowHeight > 0 ? offsetTop / rowHeight : 0,
|
|
64
|
+
ratio: Math.min(1, Math.max(0, Number(el.scrollTop || 0) / maxScroll)),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Moves the scroller so the captured position holds in the current layout.
|
|
70
|
+
* Bottom wins over the row anchor; a row that is no longer rendered falls
|
|
71
|
+
* back to the proportional position. Returns true when scrollTop changed.
|
|
72
|
+
*/
|
|
73
|
+
export function restoreTranscriptAnchor(el, anchor) {
|
|
74
|
+
if (!el || !anchor) return false;
|
|
75
|
+
const before = Number(el.scrollTop || 0);
|
|
76
|
+
if (anchor.atBottom) {
|
|
77
|
+
el.scrollTop = Number(el.scrollHeight || 0);
|
|
78
|
+
return Number(el.scrollTop || 0) !== before;
|
|
79
|
+
}
|
|
80
|
+
const messageId = String(anchor.messageId || '').trim();
|
|
81
|
+
const row = messageId
|
|
82
|
+
? transcriptRows(el).find((node) => String(node.dataset?.messageId || '').trim() === messageId)
|
|
83
|
+
: null;
|
|
84
|
+
if (row) {
|
|
85
|
+
const containerRect = el.getBoundingClientRect();
|
|
86
|
+
const rect = row.getBoundingClientRect();
|
|
87
|
+
const targetOffset = Number(anchor.offsetFraction || 0) * Math.max(0, rect.bottom - rect.top);
|
|
88
|
+
const delta = rect.top - containerRect.top - targetOffset;
|
|
89
|
+
if (Math.abs(delta) > 0.5) el.scrollTop = before + delta;
|
|
90
|
+
return Number(el.scrollTop || 0) !== before;
|
|
91
|
+
}
|
|
92
|
+
const maxScroll = Math.max(0, Number(el.scrollHeight || 0) - Number(el.clientHeight || 0));
|
|
93
|
+
el.scrollTop = Math.round(maxScroll * Number(anchor.ratio || 0));
|
|
94
|
+
return Number(el.scrollTop || 0) !== before;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const KEYBOARD_INPUT_TYPES = new Set(['', 'text', 'search', 'email', 'url', 'tel', 'number', 'password']);
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The focused field inside the transcript that has the on-screen keyboard
|
|
101
|
+
* open (a question card's reply box or form input), or null.
|
|
102
|
+
*/
|
|
103
|
+
export function focusedTranscriptTextField(el, active = globalThis.document?.activeElement) {
|
|
104
|
+
if (!el || !active || active === el) return null;
|
|
105
|
+
if (typeof el.contains !== 'function' || !el.contains(active)) return null;
|
|
106
|
+
const tag = String(active.tagName || '').toUpperCase();
|
|
107
|
+
if (tag === 'TEXTAREA') return active;
|
|
108
|
+
if (tag === 'INPUT') {
|
|
109
|
+
return KEYBOARD_INPUT_TYPES.has(String(active.type ?? '').toLowerCase()) ? active : null;
|
|
110
|
+
}
|
|
111
|
+
return active.isContentEditable === true ? active : null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Scrolls the transcript just enough to show the field whole, top-aligned
|
|
116
|
+
* when it is taller than the viewport. Arithmetic on scrollTop rather than
|
|
117
|
+
* scrollIntoView, which would scroll the page root too. Returns true when
|
|
118
|
+
* scrollTop changed.
|
|
119
|
+
*/
|
|
120
|
+
export function revealFieldInTranscript(el, field, { marginPx = 8 } = {}) {
|
|
121
|
+
if (!el || typeof field?.getBoundingClientRect !== 'function') return false;
|
|
122
|
+
const before = Number(el.scrollTop || 0);
|
|
123
|
+
const container = el.getBoundingClientRect();
|
|
124
|
+
const rect = field.getBoundingClientRect();
|
|
125
|
+
const viewTop = container.top + marginPx;
|
|
126
|
+
const viewBottom = container.bottom - marginPx;
|
|
127
|
+
let delta = 0;
|
|
128
|
+
if (rect.top < viewTop || rect.bottom - rect.top > viewBottom - viewTop) {
|
|
129
|
+
delta = rect.top - viewTop;
|
|
130
|
+
} else if (rect.bottom > viewBottom) {
|
|
131
|
+
delta = rect.bottom - viewBottom;
|
|
132
|
+
}
|
|
133
|
+
if (Math.abs(delta) <= 0.5) return false;
|
|
134
|
+
el.scrollTop = before + delta;
|
|
135
|
+
return Number(el.scrollTop || 0) !== before;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Tracks the position across resize storms. Scroll events commit the
|
|
140
|
+
* reader's position; a resize re-applies the last committed position and
|
|
141
|
+
* opens a settle window during which scroll events are ignored — they are
|
|
142
|
+
* the browser's own anchoring and our re-pins, not the reader. Rotation on
|
|
143
|
+
* a phone resizes in several steps (browser chrome animates), so the window
|
|
144
|
+
* extends with every step and the position is re-applied once more when it
|
|
145
|
+
* closes.
|
|
146
|
+
*
|
|
147
|
+
* The browser's reveal of a focused field when the keyboard shrinks the
|
|
148
|
+
* viewport is one of those ignored scrolls, so every re-apply reveals the
|
|
149
|
+
* focused transcript field again itself.
|
|
150
|
+
*/
|
|
151
|
+
export function createTranscriptResizeKeeper({
|
|
152
|
+
getElement,
|
|
153
|
+
settleMs = 500,
|
|
154
|
+
setTimer = (fn, ms) => setTimeout(fn, ms),
|
|
155
|
+
clearTimer = (id) => clearTimeout(id),
|
|
156
|
+
capture = captureTranscriptAnchor,
|
|
157
|
+
restore = restoreTranscriptAnchor,
|
|
158
|
+
getFocusedField = focusedTranscriptTextField,
|
|
159
|
+
reveal = revealFieldInTranscript,
|
|
160
|
+
} = {}) {
|
|
161
|
+
let committed = null;
|
|
162
|
+
let settleTimer = null;
|
|
163
|
+
|
|
164
|
+
function element() {
|
|
165
|
+
try { return typeof getElement === 'function' ? getElement() : null; } catch { return null; }
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function recordScroll() {
|
|
169
|
+
if (settleTimer !== null) return false;
|
|
170
|
+
const el = element();
|
|
171
|
+
if (!el) return false;
|
|
172
|
+
const next = capture(el);
|
|
173
|
+
if (next) committed = next;
|
|
174
|
+
return Boolean(next);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function apply(el) {
|
|
178
|
+
const restored = committed ? restore(el, committed) : false;
|
|
179
|
+
let field = null;
|
|
180
|
+
try { field = getFocusedField(el); } catch { field = null; }
|
|
181
|
+
const revealed = field ? reveal(el, field) : false;
|
|
182
|
+
return restored || revealed;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function endSettle() {
|
|
186
|
+
settleTimer = null;
|
|
187
|
+
const el = element();
|
|
188
|
+
if (el) apply(el);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function handleResize() {
|
|
192
|
+
const el = element();
|
|
193
|
+
if (!el) return false;
|
|
194
|
+
if (!committed) {
|
|
195
|
+
// Nothing committed yet (no scroll since load): the current position
|
|
196
|
+
// is the best description we have.
|
|
197
|
+
committed = capture(el);
|
|
198
|
+
}
|
|
199
|
+
if (settleTimer !== null) clearTimer(settleTimer);
|
|
200
|
+
settleTimer = setTimer(endSettle, settleMs);
|
|
201
|
+
return apply(el);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
recordScroll,
|
|
206
|
+
handleResize,
|
|
207
|
+
isSettling: () => settleTimer !== null,
|
|
208
|
+
committedAnchor: () => committed,
|
|
209
|
+
};
|
|
210
|
+
}
|