@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.
- package/CHANGELOG.md +478 -0
- package/README.md +129 -39
- package/bin/bat/oar.bat +46 -46
- package/bin/oar.js +814 -228
- package/package.json +5 -6
- package/server/README.md +306 -4
- package/server/claude-worker/claude-remote-relay-tool.mjs +10 -0
- package/server/claude-worker/claude-session-process.mjs +35 -1
- package/server/claude-worker/claude-turn-publisher.mjs +3 -0
- package/server/claude-worker/sdk-message-normalizer.mjs +31 -4
- package/server/claude-worker/subagent-timeline-recorder.mjs +572 -0
- package/server/copilot-worker/copilot-sdk-session-process.mjs +3 -1
- package/server/cursor-worker/cursor-sdk-adapter.mjs +56 -2
- package/server/db-schema.mjs +44 -0
- package/server/migrations/0008-message-pins.mjs +85 -0
- package/server/migrations/0009-subagent-timeline.mjs +135 -0
- package/server/migrations/0010-subagent-timeline-pruning.mjs +89 -0
- package/server/migrations/0011-relay-event-subscriptions.mjs +125 -0
- package/server/migrations/0012-cross-relay-event-subscriptions.mjs +116 -0
- package/server/public/app/agent-exchange-fold-view.mjs +248 -0
- package/server/public/app/api-client.js +91 -0
- package/server/public/app/archive-confirm.mjs +28 -0
- package/server/public/app/ask-user-view.js +3 -0
- package/server/public/app/attachments-view.js +1024 -131
- package/server/public/app/back-guard.mjs +58 -0
- package/server/public/app/background-tasks-view.mjs +18 -26
- package/server/public/app/bootstrap.js +230 -35
- package/server/public/app/chat-actions-menu-state.mjs +116 -0
- package/server/public/app/claude-cloud-conversation-ui.js +10 -22
- package/server/public/app/composer-control-state.mjs +16 -0
- package/server/public/app/composer-keyboard.mjs +54 -0
- package/server/public/app/conversation-view.js +471 -110
- package/server/public/app/explorer-edit-state.mjs +68 -0
- package/server/public/app/external-link-policy.mjs +4 -1
- package/server/public/app/file-version.mjs +58 -0
- package/server/public/app/host-suspend-ui.js +6 -3
- package/server/public/app/journal-view.js +161 -27
- package/server/public/app/no-conversation-state.mjs +58 -0
- package/server/public/app/pinned-messages-view.mjs +253 -0
- package/server/public/app/provider-readiness.mjs +120 -0
- package/server/public/app/pwa-install.js +1 -1
- package/server/public/app/relay-board-view.js +3 -0
- package/server/public/app/relay-dot-state.mjs +8 -1
- package/server/public/app/relay-event-subscriptions-ui.js +184 -0
- package/server/public/app/relay-event-view.mjs +234 -0
- package/server/public/app/router.js +8 -2
- package/server/public/app/settings-modal.js +53 -1
- package/server/public/app/socket-handlers.js +65 -28
- package/server/public/app/store.js +25 -2
- package/server/public/app/subagent-row.mjs +138 -0
- package/server/public/app/subagent-timeline-view.mjs +186 -0
- package/server/public/app/subagent-viewer-watch.mjs +50 -0
- package/server/public/app/subagent-viewer.mjs +534 -0
- package/server/public/app/task-row-format.mjs +29 -0
- package/server/public/app/thinking-follow.js +4 -0
- package/server/public/app/transcript-jump-bottom-view.mjs +290 -0
- package/server/public/app/transcript-jump-bottom.mjs +99 -0
- package/server/public/app/transcript-reload-freshness.mjs +25 -0
- package/server/public/app/update-ui.js +1 -1
- package/server/public/app/viewer-chat-gallery.mjs +59 -0
- package/server/public/app/viewer-controls-state.mjs +59 -0
- package/server/public/app/viewer-gallery.mjs +46 -0
- package/server/public/app/viewer-gestures.mjs +105 -0
- package/server/public/app/viewer-history.mjs +45 -0
- package/server/public/app/viewer-tap.mjs +112 -0
- package/server/public/index.html +700 -59
- package/server/repositories/message-pin-statements.mjs +39 -0
- package/server/repositories/message-repository.mjs +34 -3
- package/server/repositories/question-repository.mjs +9 -3
- package/server/repositories/remote-relay-repository.mjs +6 -2
- package/server/repositories/subagent-timeline-statements.mjs +170 -0
- package/server/routes/ask-user-routes.mjs +3 -0
- package/server/routes/messages-routes.mjs +548 -111
- package/server/routes/relay-event-routes.mjs +157 -0
- package/server/routes/remote-relay-routes.mjs +15 -0
- package/server/routes/sessions-routes.mjs +164 -24
- package/server/server-runtime.mjs +274 -62
- package/server/services/cloudflared-tunnel-service.mjs +139 -41
- package/server/services/explorer-entry-name.mjs +26 -0
- package/server/services/explorer-mutations.mjs +122 -0
- package/server/services/file-serving.mjs +155 -0
- package/server/services/message-pin-list.mjs +52 -0
- package/server/services/oar-cli-helpers.mjs +139 -4
- package/server/services/oar-cli-menu.mjs +240 -0
- package/server/services/oar-cli-wsl.mjs +129 -0
- package/server/services/preview-proxy-server.mjs +41 -23
- package/server/services/push-dispatch-service.mjs +18 -0
- package/server/services/relay-event-link-service.mjs +289 -0
- package/server/services/relay-event-service.mjs +1935 -0
- package/server/services/relay-shutdown-gate.mjs +31 -0
- package/server/services/remote-relay-caller-context.mjs +52 -7
- package/server/services/remote-relay-dispatcher.mjs +494 -2
- package/server/services/remote-relay-registry-service.mjs +11 -1
- package/server/services/safe-served-content.mjs +61 -11
- package/server/services/session-activity-tracker.mjs +487 -0
- package/server/services/session-history-refresh-service.mjs +31 -0
- package/server/services/stale-attempt-log-limiter.mjs +96 -0
- package/server/services/stale-recovery-clock-guard.mjs +57 -0
- package/server/services/stale-subagent-run-sweep.mjs +109 -0
- package/server/services/subagent-record-retention.mjs +164 -0
- package/server/services/subagent-timeline-service.mjs +328 -0
- package/server/services/subagent-viewer-rooms.mjs +119 -0
- package/server/services/upgrade-socket-guard.mjs +16 -0
- package/shared/relay-events.mjs +371 -0
- package/shared/remote-relay-contract.mjs +157 -5
- package/shared/subagent-record-retention.mjs +31 -0
- package/shared/subagent-timeline.mjs +79 -0
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
// The transcript's jump-to-bottom button, and the re-pin that keeps a reader
|
|
2
|
+
// who follows the end at the end when the transcript resizes under them.
|
|
3
|
+
// Decisions live in transcript-jump-bottom.mjs; this module reads the
|
|
4
|
+
// scroller and the rows and applies them.
|
|
5
|
+
//
|
|
6
|
+
// Following the end has no state of its own: every live update asks
|
|
7
|
+
// isMessagesAtBottom() before it appends and scrolls. When the composer grows
|
|
8
|
+
// (the background-task panel appears or opens, a banner, a taller draft) the
|
|
9
|
+
// transcript shrinks from below, scrollTop stays and no scroll event fires,
|
|
10
|
+
// so the reader is left that many pixels above the end and the next update
|
|
11
|
+
// stops following. The ResizeObserver here puts them back before paint.
|
|
12
|
+
|
|
13
|
+
import { currentConvId, isMessagesNearBottom, scrollBottom } from './store.js';
|
|
14
|
+
import { isConversationWindowAtTail } from './conversation-view.js';
|
|
15
|
+
import { disarmThinkingFollow, getThinkingFollowMode, syncThinkingFollowButtons } from './thinking-follow.js';
|
|
16
|
+
import { resolveNearBottomThresholdPx } from './transcript-viewport-anchor.mjs';
|
|
17
|
+
import {
|
|
18
|
+
createViewportBaseline,
|
|
19
|
+
isOnScreenKeyboardOpen,
|
|
20
|
+
shouldHoldFocusOnJump,
|
|
21
|
+
} from './composer-keyboard.mjs';
|
|
22
|
+
import {
|
|
23
|
+
isNearBottom,
|
|
24
|
+
jumpBadgeText,
|
|
25
|
+
jumpScrollBehavior,
|
|
26
|
+
nextJumpButtonState,
|
|
27
|
+
shouldRepinOnResize,
|
|
28
|
+
transcriptRowKey,
|
|
29
|
+
} from './transcript-jump-bottom.mjs';
|
|
30
|
+
|
|
31
|
+
// The final snap of a smooth jump where the browser has no scrollend event.
|
|
32
|
+
const SMOOTH_JUMP_FALLBACK_MS = 900;
|
|
33
|
+
const BUTTON_LABEL = 'Jump to the latest message';
|
|
34
|
+
|
|
35
|
+
let initialized = false;
|
|
36
|
+
let state = null;
|
|
37
|
+
let stateConversationId = '';
|
|
38
|
+
// The reader followed the end as of the last scroll event, at this scrollTop.
|
|
39
|
+
let pinned = true;
|
|
40
|
+
let pinnedScrollTop = 0;
|
|
41
|
+
// The transcript's height as of the last ResizeObserver round.
|
|
42
|
+
let observedClientHeight = null;
|
|
43
|
+
let jump = null;
|
|
44
|
+
let updateScheduled = false;
|
|
45
|
+
let loadConversationEnd = null;
|
|
46
|
+
|
|
47
|
+
function scroller() {
|
|
48
|
+
return document.getElementById('messages');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function distanceFromBottom(el) {
|
|
52
|
+
return Math.max(0, el.scrollHeight - el.clientHeight - el.scrollTop);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function rowKeys(el) {
|
|
56
|
+
const keys = [];
|
|
57
|
+
for (const node of el.children) {
|
|
58
|
+
if (!node.classList?.contains('msg')) continue;
|
|
59
|
+
const key = transcriptRowKey({
|
|
60
|
+
messageId: node.dataset.messageId,
|
|
61
|
+
exchangePromptId: node.dataset.agentExchangePromptId,
|
|
62
|
+
live: node.id === 'thinking-indicator',
|
|
63
|
+
});
|
|
64
|
+
if (key) keys.push(key);
|
|
65
|
+
}
|
|
66
|
+
return keys;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function render(visible, count) {
|
|
70
|
+
const button = document.getElementById('jump-to-bottom-btn');
|
|
71
|
+
if (!button) return;
|
|
72
|
+
button.classList.toggle('visible', visible);
|
|
73
|
+
const badge = button.querySelector('.jump-to-bottom-badge');
|
|
74
|
+
const text = visible ? jumpBadgeText(count) : '';
|
|
75
|
+
if (badge) {
|
|
76
|
+
badge.textContent = text;
|
|
77
|
+
badge.hidden = !text;
|
|
78
|
+
}
|
|
79
|
+
const label = text ? `${BUTTON_LABEL} (${count} new)` : BUTTON_LABEL;
|
|
80
|
+
if (button.getAttribute('aria-label') !== label) button.setAttribute('aria-label', label);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function update() {
|
|
84
|
+
updateScheduled = false;
|
|
85
|
+
const el = scroller();
|
|
86
|
+
if (!el) return;
|
|
87
|
+
const conversationId = String(currentConvId || '').trim();
|
|
88
|
+
if (conversationId !== stateConversationId) {
|
|
89
|
+
state = null;
|
|
90
|
+
stateConversationId = conversationId;
|
|
91
|
+
}
|
|
92
|
+
if (!conversationId || jump) {
|
|
93
|
+
render(false, 0);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const distance = distanceFromBottom(el);
|
|
97
|
+
const nearBottomPx = resolveNearBottomThresholdPx(el.clientHeight);
|
|
98
|
+
state = nextJumpButtonState(state, {
|
|
99
|
+
distance,
|
|
100
|
+
clientHeight: el.clientHeight,
|
|
101
|
+
nearBottomPx,
|
|
102
|
+
rowKeys: isNearBottom(distance, nearBottomPx) ? [] : rowKeys(el),
|
|
103
|
+
});
|
|
104
|
+
render(state.visible, state.count);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function scheduleUpdate() {
|
|
108
|
+
if (updateScheduled) return;
|
|
109
|
+
updateScheduled = true;
|
|
110
|
+
requestAnimationFrame(update);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// A scroll event is dispatched a frame after the scroll, before that frame's
|
|
114
|
+
// ResizeObserver round: when the composer grew in between, the transcript
|
|
115
|
+
// has already shrunk, and the reader would read as having left the end. So
|
|
116
|
+
// the position is judged at the height the observer last saw; a resize it
|
|
117
|
+
// has not handled yet is its own to judge.
|
|
118
|
+
function notePosition(el) {
|
|
119
|
+
const height = observedClientHeight ?? el.clientHeight;
|
|
120
|
+
const distance = Math.max(0, el.scrollHeight - height - el.scrollTop);
|
|
121
|
+
pinned = isNearBottom(distance, resolveNearBottomThresholdPx(height));
|
|
122
|
+
pinnedScrollTop = el.scrollTop;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function endJump() {
|
|
126
|
+
if (!jump) return;
|
|
127
|
+
jump.cleanup();
|
|
128
|
+
jump = null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Rows that landed during the animation were not followed (the reader was
|
|
132
|
+
// not at the end yet): the end is wherever it is now.
|
|
133
|
+
function finishJump() {
|
|
134
|
+
endJump();
|
|
135
|
+
const el = scroller();
|
|
136
|
+
scrollBottom();
|
|
137
|
+
if (el) notePosition(el);
|
|
138
|
+
state = null;
|
|
139
|
+
scheduleUpdate();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function startSmoothJump(el) {
|
|
143
|
+
endJump();
|
|
144
|
+
// A wheel or a touch during the animation is the reader taking over.
|
|
145
|
+
const takeOver = () => {
|
|
146
|
+
endJump();
|
|
147
|
+
notePosition(el);
|
|
148
|
+
scheduleUpdate();
|
|
149
|
+
};
|
|
150
|
+
const timer = setTimeout(finishJump, SMOOTH_JUMP_FALLBACK_MS);
|
|
151
|
+
el.addEventListener('scrollend', finishJump);
|
|
152
|
+
el.addEventListener('wheel', takeOver, { passive: true });
|
|
153
|
+
el.addEventListener('touchstart', takeOver, { passive: true });
|
|
154
|
+
jump = {
|
|
155
|
+
cleanup() {
|
|
156
|
+
clearTimeout(timer);
|
|
157
|
+
el.removeEventListener('scrollend', finishJump);
|
|
158
|
+
el.removeEventListener('wheel', takeOver);
|
|
159
|
+
el.removeEventListener('touchstart', takeOver);
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function prefersReducedMotion() {
|
|
166
|
+
try { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; } catch { return false; }
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function jumpTranscriptToBottom() {
|
|
170
|
+
const el = scroller();
|
|
171
|
+
if (!el) return;
|
|
172
|
+
// The end wins over a followed section of the live bubble.
|
|
173
|
+
if (getThinkingFollowMode()) {
|
|
174
|
+
disarmThinkingFollow();
|
|
175
|
+
syncThinkingFollowButtons(document, null);
|
|
176
|
+
}
|
|
177
|
+
state = null;
|
|
178
|
+
render(false, 0);
|
|
179
|
+
// A window in the middle of the history (a search result, a pin) ends
|
|
180
|
+
// above the conversation's end: the end is loaded instead.
|
|
181
|
+
if (loadConversationEnd && !isConversationWindowAtTail()) {
|
|
182
|
+
endJump();
|
|
183
|
+
jump = { cleanup() {} };
|
|
184
|
+
void Promise.resolve()
|
|
185
|
+
.then(() => loadConversationEnd())
|
|
186
|
+
.catch(() => {})
|
|
187
|
+
.finally(finishJump);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
const behavior = jumpScrollBehavior({
|
|
191
|
+
distance: distanceFromBottom(el),
|
|
192
|
+
clientHeight: el.clientHeight,
|
|
193
|
+
reducedMotion: prefersReducedMotion(),
|
|
194
|
+
});
|
|
195
|
+
if (behavior === 'smooth') startSmoothJump(el);
|
|
196
|
+
else finishJump();
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function handleScroll() {
|
|
200
|
+
const el = scroller();
|
|
201
|
+
if (!el) return;
|
|
202
|
+
if (jump) {
|
|
203
|
+
if (distanceFromBottom(el) <= 1) finishJump();
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
notePosition(el);
|
|
207
|
+
scheduleUpdate();
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function handleResize() {
|
|
211
|
+
const el = scroller();
|
|
212
|
+
if (!el) return;
|
|
213
|
+
// A followed section of the live bubble pins itself (thinking-follow.js).
|
|
214
|
+
if (!jump && !getThinkingFollowMode() && distanceFromBottom(el) > 0 && shouldRepinOnResize({
|
|
215
|
+
pinned,
|
|
216
|
+
pinnedScrollTop,
|
|
217
|
+
scrollTop: el.scrollTop,
|
|
218
|
+
nearBottomNow: isMessagesNearBottom(),
|
|
219
|
+
})) {
|
|
220
|
+
scrollBottom();
|
|
221
|
+
}
|
|
222
|
+
observedClientHeight = el.clientHeight;
|
|
223
|
+
notePosition(el);
|
|
224
|
+
scheduleUpdate();
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const viewportBaseline = createViewportBaseline();
|
|
228
|
+
|
|
229
|
+
function noteViewport() {
|
|
230
|
+
const height = window.visualViewport?.height || window.innerHeight || 0;
|
|
231
|
+
return { height, tallest: viewportBaseline.note(window.innerWidth || 0, height) };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function isEditableElement(node) {
|
|
235
|
+
return !!node && (node.tagName === 'TEXTAREA' || node.tagName === 'INPUT' || node.isContentEditable === true);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// A tap that keeps a focused field focused raises the phone's keyboard even
|
|
239
|
+
// when none was showing (the composer can be focused with the keyboard
|
|
240
|
+
// down). So the button holds the focus for a mouse, or while the keyboard is
|
|
241
|
+
// open; otherwise the field lets it go.
|
|
242
|
+
function handleButtonPointerDown(event) {
|
|
243
|
+
const active = document.activeElement;
|
|
244
|
+
const { height, tallest } = noteViewport();
|
|
245
|
+
const keyboardOpen = isOnScreenKeyboardOpen({
|
|
246
|
+
editableFocused: isEditableElement(active),
|
|
247
|
+
visibleHeight: height,
|
|
248
|
+
tallestHeight: tallest,
|
|
249
|
+
});
|
|
250
|
+
if (shouldHoldFocusOnJump({ pointerType: event.pointerType, keyboardOpen })) {
|
|
251
|
+
event.preventDefault();
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
if (isEditableElement(active)) {
|
|
255
|
+
try { active.blur(); } catch {}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* `loadEnd` loads the conversation's end in place of a history window; the
|
|
261
|
+
* button calls it when the page holds a window in the middle of the history.
|
|
262
|
+
*/
|
|
263
|
+
export function initTranscriptJumpToBottom({ loadEnd } = {}) {
|
|
264
|
+
if (typeof loadEnd === 'function') loadConversationEnd = loadEnd;
|
|
265
|
+
if (initialized) return;
|
|
266
|
+
const el = scroller();
|
|
267
|
+
const button = document.getElementById('jump-to-bottom-btn');
|
|
268
|
+
if (!el || !button) return;
|
|
269
|
+
initialized = true;
|
|
270
|
+
pinnedScrollTop = el.scrollTop;
|
|
271
|
+
observedClientHeight = el.clientHeight;
|
|
272
|
+
button.addEventListener('click', (event) => {
|
|
273
|
+
event.preventDefault();
|
|
274
|
+
jumpTranscriptToBottom();
|
|
275
|
+
});
|
|
276
|
+
// With the keyboard open, a tap must not take the composer's focus (and
|
|
277
|
+
// with it the keyboard) away; with it closed, it must not raise it.
|
|
278
|
+
button.addEventListener('pointerdown', handleButtonPointerDown);
|
|
279
|
+
noteViewport();
|
|
280
|
+
window.visualViewport?.addEventListener?.('resize', noteViewport, { passive: true });
|
|
281
|
+
window.addEventListener('resize', noteViewport, { passive: true });
|
|
282
|
+
el.addEventListener('scroll', handleScroll, { passive: true });
|
|
283
|
+
if (typeof MutationObserver === 'function') {
|
|
284
|
+
new MutationObserver(scheduleUpdate).observe(el, { childList: true });
|
|
285
|
+
}
|
|
286
|
+
if (typeof ResizeObserver === 'function') {
|
|
287
|
+
new ResizeObserver(handleResize).observe(el);
|
|
288
|
+
}
|
|
289
|
+
scheduleUpdate();
|
|
290
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// The transcript's jump-to-bottom button, without the DOM: when it shows,
|
|
2
|
+
// what its badge counts, how far a click scrolls smoothly, and whether a
|
|
3
|
+
// resize of the transcript re-pins a reader who was following the end.
|
|
4
|
+
// transcript-jump-bottom-view.mjs reads the scroller and the rows and applies
|
|
5
|
+
// what this module decides.
|
|
6
|
+
//
|
|
7
|
+
// "At the bottom" is the composer's near-bottom rule (isMessagesNearBottom,
|
|
8
|
+
// resolveNearBottomThresholdPx); the caller passes its threshold in.
|
|
9
|
+
|
|
10
|
+
/** The button fades in once the reader is this many screens above the end. */
|
|
11
|
+
export const JUMP_BUTTON_SHOW_SCREENS = 1;
|
|
12
|
+
/** Further than this many screens a click jumps at once instead of scrolling. */
|
|
13
|
+
export const JUMP_SMOOTH_MAX_SCREENS = 3;
|
|
14
|
+
export const JUMP_BADGE_MAX = 99;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* One key per row the reader sees. Every row of a folded agent exchange
|
|
18
|
+
* (its prompt, its replies, its live bubble) carries the prompt's id, so the
|
|
19
|
+
* fold counts once however many of its rows are on the page. The live
|
|
20
|
+
* bubble of a turn outside a fold has the prompt's id as its message id, so
|
|
21
|
+
* it gets a key of its own.
|
|
22
|
+
*/
|
|
23
|
+
export function transcriptRowKey({ messageId = '', exchangePromptId = '', live = false } = {}) {
|
|
24
|
+
const fold = String(exchangePromptId || '').trim();
|
|
25
|
+
if (fold) return `fold:${fold}`;
|
|
26
|
+
const id = String(messageId || '').trim();
|
|
27
|
+
if (!id) return '';
|
|
28
|
+
return live ? `live:${id}` : `msg:${id}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Rows that arrived below the reader since `baselineKeys` was taken: keys
|
|
33
|
+
* after the last row the baseline knows, not in the baseline, each once.
|
|
34
|
+
* Older history paged in above, or a row re-rendered under a new node, is not
|
|
35
|
+
* news. A transcript sharing no row with the baseline is another window (a
|
|
36
|
+
* reload, a jump into the history): nothing is counted there.
|
|
37
|
+
*/
|
|
38
|
+
export function countNewRows(baselineKeys, currentKeys) {
|
|
39
|
+
const baseline = new Set((baselineKeys || []).filter(Boolean));
|
|
40
|
+
const current = (currentKeys || []).filter(Boolean);
|
|
41
|
+
let lastKnown = -1;
|
|
42
|
+
current.forEach((key, index) => {
|
|
43
|
+
if (baseline.has(key)) lastKnown = index;
|
|
44
|
+
});
|
|
45
|
+
if (lastKnown < 0 && baseline.size) return 0;
|
|
46
|
+
const fresh = new Set();
|
|
47
|
+
for (const key of current.slice(lastKnown + 1)) {
|
|
48
|
+
if (!baseline.has(key)) fresh.add(key);
|
|
49
|
+
}
|
|
50
|
+
return fresh.size;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function isNearBottom(distance, nearBottomPx) {
|
|
54
|
+
return Math.max(0, Number(distance) || 0) <= Math.max(0, Number(nearBottomPx) || 0);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The button's next state from the scroller's geometry. At the bottom it
|
|
59
|
+
* hides and forgets the baseline. Away from it, the rows on the page when the
|
|
60
|
+
* reader left are the baseline the badge counts from; the button shows from
|
|
61
|
+
* one screen above the end and, once shown, stays until the bottom.
|
|
62
|
+
*/
|
|
63
|
+
export function nextJumpButtonState(previous, { distance, clientHeight, nearBottomPx, rowKeys = [] } = {}) {
|
|
64
|
+
if (isNearBottom(distance, nearBottomPx)) return { visible: false, baseline: null, count: 0 };
|
|
65
|
+
const baseline = Array.isArray(previous?.baseline) ? previous.baseline : rowKeys.slice();
|
|
66
|
+
const screens = Number(clientHeight) > 0 ? Number(distance) / Number(clientHeight) : 0;
|
|
67
|
+
return {
|
|
68
|
+
visible: previous?.visible === true || screens >= JUMP_BUTTON_SHOW_SCREENS,
|
|
69
|
+
baseline,
|
|
70
|
+
count: countNewRows(baseline, rowKeys),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** 'smooth' up to JUMP_SMOOTH_MAX_SCREENS above the end, else 'instant'. */
|
|
75
|
+
export function jumpScrollBehavior({ distance, clientHeight, reducedMotion = false } = {}) {
|
|
76
|
+
if (reducedMotion) return 'instant';
|
|
77
|
+
const height = Number(clientHeight);
|
|
78
|
+
if (!(height > 0)) return 'instant';
|
|
79
|
+
return Number(distance) / height <= JUMP_SMOOTH_MAX_SCREENS ? 'smooth' : 'instant';
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function jumpBadgeText(count) {
|
|
83
|
+
const value = Math.max(0, Math.trunc(Number(count) || 0));
|
|
84
|
+
if (!value) return '';
|
|
85
|
+
return value > JUMP_BADGE_MAX ? `${JUMP_BADGE_MAX}+` : String(value);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Whether a resize of the transcript (the composer or the background-task
|
|
90
|
+
* panel growing under it) re-pins it to the end. The scroller shrinking from
|
|
91
|
+
* below keeps scrollTop and fires no scroll event, so a reader who was
|
|
92
|
+
* following is still described by the last scroll: `pinned` and the
|
|
93
|
+
* scrollTop it was taken at. Once scrollTop has moved since (a restore, a
|
|
94
|
+
* clamp, the browser's anchoring), the geometry as it is now decides.
|
|
95
|
+
*/
|
|
96
|
+
export function shouldRepinOnResize({ pinned, pinnedScrollTop, scrollTop, nearBottomNow }) {
|
|
97
|
+
if (pinned && Number(scrollTop) === Number(pinnedScrollTop)) return true;
|
|
98
|
+
return nearBottomNow === true;
|
|
99
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// A reload of the transcript rebuilds it from what its request returned. A
|
|
2
|
+
// row the socket put on the page while that request was on its way can be
|
|
3
|
+
// missing from the answer (the relay stored it after the request read the
|
|
4
|
+
// conversation), and the rebuild would take it off the page again until the
|
|
5
|
+
// next reload. The socket's rows are counted (getTranscriptLiveRowEpoch in
|
|
6
|
+
// conversation-view.js); when the count moved during the request, the
|
|
7
|
+
// request is made again, now that the row is stored.
|
|
8
|
+
|
|
9
|
+
/** Requests before a reload gives up on an answer that keeps going stale. */
|
|
10
|
+
export const LIVE_ROW_RELOAD_ATTEMPTS = 3;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* `stale` is true when rows still arrived during the last attempt: the page
|
|
14
|
+
* holds rows the answer lacks, so the caller leaves the transcript alone and
|
|
15
|
+
* the next reload asks again.
|
|
16
|
+
*/
|
|
17
|
+
export async function loadKeepingLiveRows(load, getLiveRowEpoch, { attempts = LIVE_ROW_RELOAD_ATTEMPTS } = {}) {
|
|
18
|
+
let response = null;
|
|
19
|
+
for (let attempt = 1; attempt <= Math.max(1, attempts); attempt += 1) {
|
|
20
|
+
const epoch = getLiveRowEpoch();
|
|
21
|
+
response = await load();
|
|
22
|
+
if (!response || getLiveRowEpoch() === epoch) return { response, stale: false };
|
|
23
|
+
}
|
|
24
|
+
return { response, stale: true };
|
|
25
|
+
}
|
|
@@ -100,7 +100,7 @@ export function renderUpdateSection() {
|
|
|
100
100
|
status.textContent = install.state === 'running'
|
|
101
101
|
? `Updating to ${install.targetVersion}…`
|
|
102
102
|
: install.state === 'success'
|
|
103
|
-
? 'npm finished — the relay restarts
|
|
103
|
+
? 'npm finished — the relay restarts once the running turns finish.'
|
|
104
104
|
: `Update failed: ${install.error || 'unknown error'}`;
|
|
105
105
|
}
|
|
106
106
|
const logPre = byId('update-install-log-pre');
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// The media of a conversation as a gallery: every image, video and audio
|
|
2
|
+
// attachment and every picture embedded in a bubble, in the order they
|
|
3
|
+
// stand in the conversation, open at the one that was tapped.
|
|
4
|
+
//
|
|
5
|
+
// It is read from the rendered messages rather than from message records:
|
|
6
|
+
// an embedded picture only becomes an address the viewer can open once the
|
|
7
|
+
// bubble is rendered (local paths are rewritten then), and the rendered
|
|
8
|
+
// order is the conversation's order.
|
|
9
|
+
|
|
10
|
+
export const CHAT_MEDIA_SELECTOR = '.msg-attachment[data-media-url], .msg-bubble img';
|
|
11
|
+
|
|
12
|
+
function baseName(filePath) {
|
|
13
|
+
return String(filePath || '').split(/[\\/]/).filter(Boolean).pop() || 'image';
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** The viewer item behind a media node, or null for a node that is none. */
|
|
17
|
+
export function chatMediaItemFromNode(node, { parseAppFileHref } = {}) {
|
|
18
|
+
if (!node) return null;
|
|
19
|
+
const attachment = node.closest?.('.msg-attachment[data-media-url]');
|
|
20
|
+
if (attachment) {
|
|
21
|
+
return {
|
|
22
|
+
source: 'upload',
|
|
23
|
+
href: String(attachment.dataset.mediaUrl || ''),
|
|
24
|
+
name: String(attachment.dataset.mediaName || 'attachment'),
|
|
25
|
+
mime: String(attachment.dataset.mediaType || ''),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
if (String(node.tagName || '').toUpperCase() !== 'IMG') return null;
|
|
29
|
+
const src = String(node.getAttribute('src') || '').trim();
|
|
30
|
+
if (!src) return null;
|
|
31
|
+
const target = parseAppFileHref?.(src) || null;
|
|
32
|
+
if (target?.kind === 'drive') return { source: 'drives', path: target.path, name: baseName(target.path) };
|
|
33
|
+
if (target?.kind === 'workspace') return { source: 'workspace', path: target.path, name: baseName(target.path) };
|
|
34
|
+
// A data: thumbnail or a picture from the web: shown as it is.
|
|
35
|
+
const dataMime = /^data:(image\/[a-z0-9.+-]+)/i.exec(src)?.[1] || '';
|
|
36
|
+
return { source: 'upload', href: src, name: String(node.getAttribute('alt') || 'image'), mime: dataMime || 'image/*' };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The gallery of `root`'s media open at `clickedNode`, or null when there
|
|
41
|
+
* is nothing else to move to (the viewer then shows the one file).
|
|
42
|
+
* Embedded pictures can be left out (the shared view cannot fetch them).
|
|
43
|
+
*/
|
|
44
|
+
export function chatMediaGallery(root, clickedNode, { includeEmbedded = true, parseAppFileHref } = {}) {
|
|
45
|
+
if (!root || !clickedNode) return null;
|
|
46
|
+
const clicked = clickedNode.closest?.('.msg-attachment[data-media-url]') || clickedNode;
|
|
47
|
+
const items = [];
|
|
48
|
+
let index = -1;
|
|
49
|
+
for (const node of root.querySelectorAll(CHAT_MEDIA_SELECTOR)) {
|
|
50
|
+
const isAttachment = node.matches('.msg-attachment');
|
|
51
|
+
if (!isAttachment && (node.closest('.msg-attachment') || !includeEmbedded)) continue;
|
|
52
|
+
const item = chatMediaItemFromNode(node, { parseAppFileHref });
|
|
53
|
+
if (!item) continue;
|
|
54
|
+
if (node === clicked) index = items.length;
|
|
55
|
+
items.push(item);
|
|
56
|
+
}
|
|
57
|
+
if (index === -1 || items.length < 2) return null;
|
|
58
|
+
return { source: 'chat', items, index };
|
|
59
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// When the fullscreen viewer's hovering controls are shown.
|
|
2
|
+
//
|
|
3
|
+
// A single tap on the content toggles them. While a video or audio file
|
|
4
|
+
// plays they also fade out on their own after a moment, as players do; any
|
|
5
|
+
// interaction brings them back and starts that clock again. Pausing shows
|
|
6
|
+
// them and stops the clock, so a paused player is never left bare. Images
|
|
7
|
+
// and text have no clock: their controls stay until the next tap.
|
|
8
|
+
|
|
9
|
+
export const CONTROLS_AUTO_HIDE_MS = 3000;
|
|
10
|
+
|
|
11
|
+
export function createControlsState({
|
|
12
|
+
autoHideMs = CONTROLS_AUTO_HIDE_MS,
|
|
13
|
+
onChange = () => {},
|
|
14
|
+
setTimeoutImpl = setTimeout,
|
|
15
|
+
clearTimeoutImpl = clearTimeout,
|
|
16
|
+
} = {}) {
|
|
17
|
+
const state = { visible: true, playing: false, timer: null };
|
|
18
|
+
|
|
19
|
+
const stopClock = () => {
|
|
20
|
+
if (state.timer === null) return;
|
|
21
|
+
clearTimeoutImpl(state.timer);
|
|
22
|
+
state.timer = null;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const set = (visible) => {
|
|
26
|
+
if (state.visible === visible) return;
|
|
27
|
+
state.visible = visible;
|
|
28
|
+
onChange(visible);
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// The clock runs only while the controls are up and media plays.
|
|
32
|
+
const armClock = () => {
|
|
33
|
+
stopClock();
|
|
34
|
+
if (!state.playing || !state.visible) return;
|
|
35
|
+
state.timer = setTimeoutImpl(() => {
|
|
36
|
+
state.timer = null;
|
|
37
|
+
set(false);
|
|
38
|
+
}, autoHideMs);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
get visible() { return state.visible; },
|
|
43
|
+
get playing() { return state.playing; },
|
|
44
|
+
show() { set(true); armClock(); },
|
|
45
|
+
hide() { stopClock(); set(false); },
|
|
46
|
+
toggle() { if (state.visible) this.hide(); else this.show(); },
|
|
47
|
+
/** The user touched a control or moved the mouse: keep the controls a while longer. */
|
|
48
|
+
interaction() { if (state.visible) armClock(); else this.show(); },
|
|
49
|
+
setPlaying(playing) {
|
|
50
|
+
const next = playing === true;
|
|
51
|
+
if (state.playing === next) return;
|
|
52
|
+
state.playing = next;
|
|
53
|
+
if (next) armClock();
|
|
54
|
+
else { stopClock(); set(true); }
|
|
55
|
+
},
|
|
56
|
+
reset() { stopClock(); state.playing = false; set(true); },
|
|
57
|
+
dispose() { stopClock(); },
|
|
58
|
+
};
|
|
59
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// The files a viewer can move between without being closed: the files of
|
|
2
|
+
// the folder a file was opened from, in the order the browser shows them.
|
|
3
|
+
// An index into that list is the open file; neighbours are the files on
|
|
4
|
+
// either side, and the list never wraps.
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A gallery over a folder listing (`children` as the repo browser holds
|
|
8
|
+
* them; directories are skipped). `null` when the opened file is not in the
|
|
9
|
+
* list or has no neighbours — a viewer without a gallery shows one file.
|
|
10
|
+
*/
|
|
11
|
+
export function galleryFromFolder(children, openedPath, { source = 'workspace' } = {}) {
|
|
12
|
+
const items = (Array.isArray(children) ? children : [])
|
|
13
|
+
.filter((child) => child && child.type !== 'dir' && String(child.path || ''))
|
|
14
|
+
.map((child) => ({ path: String(child.path), name: String(child.name || '') }));
|
|
15
|
+
const index = items.findIndex((item) => item.path === String(openedPath || ''));
|
|
16
|
+
if (index === -1 || items.length < 2) return null;
|
|
17
|
+
return { source: String(source || 'workspace'), items, index };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The index `delta` steps away, or -1 past either end. */
|
|
21
|
+
export function galleryNeighborIndex(gallery, delta) {
|
|
22
|
+
if (!gallery || !Array.isArray(gallery.items)) return -1;
|
|
23
|
+
const next = Number(gallery.index) + Number(delta);
|
|
24
|
+
return Number.isInteger(next) && next >= 0 && next < gallery.items.length ? next : -1;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The same gallery, open at another index. */
|
|
28
|
+
export function galleryAt(gallery, index) {
|
|
29
|
+
if (!gallery || index < 0 || index >= gallery.items.length) return null;
|
|
30
|
+
return { ...gallery, index };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function galleryPositionLabel(gallery) {
|
|
34
|
+
if (!gallery || !Array.isArray(gallery.items) || !gallery.items.length) return '';
|
|
35
|
+
return `${gallery.index + 1} / ${gallery.items.length}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The items on either side of the open one, nearest first. */
|
|
39
|
+
export function galleryNeighbors(gallery) {
|
|
40
|
+
const out = [];
|
|
41
|
+
for (const delta of [1, -1]) {
|
|
42
|
+
const index = galleryNeighborIndex(gallery, delta);
|
|
43
|
+
if (index !== -1) out.push(gallery.items[index]);
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
}
|