hop2 0.11.21 → 0.11.22
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/hay/apps/server/src/rooms.ts +9 -7
- package/hay/apps/web/src/App.tsx +57 -4
- package/hay/apps/web/src/components/SessionSwitcher.tsx +6 -4
- package/hay/apps/web/src/utils/scrollFlywheel.ts +177 -20
- package/hay/apps/web/test/scrollFlywheel.test.ts +212 -0
- package/hay-web/assets/index-COQpy7gK.js +134 -0
- package/hay-web/assets/index-COQpy7gK.js.map +1 -0
- package/hay-web/assets/index-CXZrhZBl.js +134 -0
- package/hay-web/assets/index-CXZrhZBl.js.map +1 -0
- package/hay-web/assets/index-Chtgn1Ot.js +134 -0
- package/hay-web/assets/index-Chtgn1Ot.js.map +1 -0
- package/hay-web/assets/index-DcF33Fu1.js +134 -0
- package/hay-web/assets/index-DcF33Fu1.js.map +1 -0
- package/hay-web/assets/index-JN4pNZh2.js +134 -0
- package/hay-web/assets/index-JN4pNZh2.js.map +1 -0
- package/hay-web/assets/index-tyqviMzG.js +134 -0
- package/hay-web/assets/index-tyqviMzG.js.map +1 -0
- package/hay-web/index.html +1 -1
- package/hop +177 -47
- package/package.json +1 -1
- package/scripts/hay-host.js +32 -1
- package/scripts/salvage-and-restart-host.js +6 -1
- package/tests/terminal-api.integration.test.cjs +129 -27
|
@@ -73,15 +73,17 @@ export type RoomSummary = {
|
|
|
73
73
|
localCliCount: number;
|
|
74
74
|
};
|
|
75
75
|
|
|
76
|
-
// Raw output retained for reattach snapshots.
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
//
|
|
76
|
+
// Raw output retained for reattach snapshots. Sized for GC, not just
|
|
77
|
+
// features: rings live on the V8 heap as strings, and at 20MB x 24 rooms the
|
|
78
|
+
// host carried a ~350MB live heap whose major GCs stop the world 3-4s —
|
|
79
|
+
// measured on the fleet (2026-08-17: 340 event-loop stalls in a day, every
|
|
80
|
+
// one freezing typing everywhere; MarkCompact dominated the CPU sample).
|
|
81
|
+
// 6MB still exceeds every reader: the deep-history replay asks 1.5MB, the
|
|
82
|
+
// serialized attach reads the grid, and `hop restore` persists 64KB tails.
|
|
83
|
+
// Override with HAY_SNAPSHOT_BUFFER_BYTES for deeper rings at GC's expense.
|
|
82
84
|
const MAX_BUFFER_SIZE = (() => {
|
|
83
85
|
const env = Number(process.env.HAY_SNAPSHOT_BUFFER_BYTES);
|
|
84
|
-
return Number.isFinite(env) && env > 0 ? env :
|
|
86
|
+
return Number.isFinite(env) && env > 0 ? env : 6_000_000;
|
|
85
87
|
})();
|
|
86
88
|
// Keep rooms alive indefinitely; only explicit kill/remove should end a session.
|
|
87
89
|
const CLEANUP_DELAY_MS = 0;
|
package/hay/apps/web/src/App.tsx
CHANGED
|
@@ -366,6 +366,9 @@ const App = () => {
|
|
|
366
366
|
return null;
|
|
367
367
|
});
|
|
368
368
|
const [sessionLabel, setSessionLabel] = useState(() => initialRoom);
|
|
369
|
+
// The room's invariant id, as the server reported it in `hello` — the
|
|
370
|
+
// session.room string is just the display name the page was opened with.
|
|
371
|
+
const [canonicalRoomId, setCanonicalRoomId] = useState<string | null>(null);
|
|
369
372
|
const [liveCwd, setLiveCwd] = useState<string | null>(null);
|
|
370
373
|
const [status, setStatus] = useState<ConnectionStatus>("idle");
|
|
371
374
|
// What the CHROME shows. Most drops (a tunnel restart, a laptop waking, an
|
|
@@ -495,11 +498,19 @@ const App = () => {
|
|
|
495
498
|
return DEFAULT_SESSION_SWITCH_MODE;
|
|
496
499
|
});
|
|
497
500
|
|
|
501
|
+
// The label is DERIVED, never copied: the room key is the session's
|
|
502
|
+
// invariant id, which equals the display name only until the first rename.
|
|
503
|
+
// This effect used to write the room key straight into the label, so the
|
|
504
|
+
// footer (and tab title, share link, bell notification) showed the
|
|
505
|
+
// pre-rename name forever on any renamed session. The list refresh is the
|
|
506
|
+
// source of truth; session_renamed messages update it instantly in between.
|
|
498
507
|
useEffect(() => {
|
|
499
|
-
if (session?.room)
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
508
|
+
if (!session?.room) return;
|
|
509
|
+
const entry = sessions.find((s) =>
|
|
510
|
+
(s.internalName || s.name) === (canonicalRoomId || session.room)
|
|
511
|
+
|| (s.internalName || s.name) === session.room);
|
|
512
|
+
setSessionLabel(entry ? (entry.displayName || entry.name) : session.room);
|
|
513
|
+
}, [session?.room, sessions, canonicalRoomId]);
|
|
503
514
|
const [fontSize, setFontSize] = useState(() => {
|
|
504
515
|
const saved = localStorage.getItem("hay_font_size");
|
|
505
516
|
return saved ? parseInt(saved, 10) : 14;
|
|
@@ -1001,6 +1012,41 @@ const App = () => {
|
|
|
1001
1012
|
catch { return []; }
|
|
1002
1013
|
})());
|
|
1003
1014
|
|
|
1015
|
+
// ── Stale-bundle self-healing ─────────────────────────────────────────
|
|
1016
|
+
// Deploys replace the hashed bundle, but an open tab reconnects forever on
|
|
1017
|
+
// the code it loaded — every fix shipped "didn't work" until each tab was
|
|
1018
|
+
// manually reloaded (the scroll dead-zone report was a tab three bundles
|
|
1019
|
+
// behind). Poll our own index.html for the bundle it now names; when it
|
|
1020
|
+
// differs from the one running, reload — but only while the tab is HIDDEN
|
|
1021
|
+
// and the terminal has no buffered input, so nobody ever sees it happen.
|
|
1022
|
+
const staleBundleRef = useRef(false);
|
|
1023
|
+
useEffect(() => {
|
|
1024
|
+
let disposed = false;
|
|
1025
|
+
const loadedBundle = (() => {
|
|
1026
|
+
try { return new URL(import.meta.url).pathname.split("/").pop() || null; }
|
|
1027
|
+
catch { return null; }
|
|
1028
|
+
})();
|
|
1029
|
+
if (!loadedBundle) return;
|
|
1030
|
+
const check = async () => {
|
|
1031
|
+
try {
|
|
1032
|
+
const res = await fetch("/", { cache: "no-store" });
|
|
1033
|
+
const html = await res.text();
|
|
1034
|
+
const current = (html.match(/assets\/(index-[^"']+\.js)/) || [])[1];
|
|
1035
|
+
if (!disposed && current && current !== loadedBundle) staleBundleRef.current = true;
|
|
1036
|
+
} catch { /* offline — try again next tick */ }
|
|
1037
|
+
};
|
|
1038
|
+
const maybeReload = () => {
|
|
1039
|
+
if (staleBundleRef.current && document.hidden) window.location.reload();
|
|
1040
|
+
};
|
|
1041
|
+
const interval = window.setInterval(() => { void check().then(maybeReload); }, 5 * 60 * 1000);
|
|
1042
|
+
document.addEventListener("visibilitychange", maybeReload);
|
|
1043
|
+
return () => {
|
|
1044
|
+
disposed = true;
|
|
1045
|
+
window.clearInterval(interval);
|
|
1046
|
+
document.removeEventListener("visibilitychange", maybeReload);
|
|
1047
|
+
};
|
|
1048
|
+
}, []);
|
|
1049
|
+
|
|
1004
1050
|
useEffect(() => {
|
|
1005
1051
|
activeSessionRoomRef.current = session?.room ?? null;
|
|
1006
1052
|
const room = session?.room;
|
|
@@ -1510,6 +1556,8 @@ const App = () => {
|
|
|
1510
1556
|
|
|
1511
1557
|
const connect = (nextSession: { name: string; room: string }) => {
|
|
1512
1558
|
const targetRoom = nextSession.room;
|
|
1559
|
+
// A new target invalidates the previous room's id; hello re-supplies it.
|
|
1560
|
+
setCanonicalRoomId(null);
|
|
1513
1561
|
// Clear any pending reconnect
|
|
1514
1562
|
if (reconnectTimerRef.current) {
|
|
1515
1563
|
window.clearTimeout(reconnectTimerRef.current);
|
|
@@ -1652,6 +1700,11 @@ const App = () => {
|
|
|
1652
1700
|
setClientId(message.clientId);
|
|
1653
1701
|
setCollabMode(message.collabMode);
|
|
1654
1702
|
setControllerId(message.controllerId);
|
|
1703
|
+
// The server names the room by its INVARIANT id here. The page
|
|
1704
|
+
// itself only knows the display name it was opened under, which
|
|
1705
|
+
// goes stale on the first rename — this is the durable key the
|
|
1706
|
+
// label derivation matches the session list against.
|
|
1707
|
+
if (message.roomId) setCanonicalRoomId(message.roomId);
|
|
1655
1708
|
break;
|
|
1656
1709
|
case "presence":
|
|
1657
1710
|
// Presence broadcasts arrive on every peer keystroke (typing
|
|
@@ -2361,7 +2361,7 @@ export const SessionSwitcher = ({
|
|
|
2361
2361
|
headers: { "Content-Type": "application/json" },
|
|
2362
2362
|
body: JSON.stringify({ name: next, type: "terminal", port: null })
|
|
2363
2363
|
});
|
|
2364
|
-
const data = await res.json().catch(() => ({} as { name?: string; error?: string }));
|
|
2364
|
+
const data = await res.json().catch(() => ({} as { name?: string; displayName?: string; internalName?: string; error?: string }));
|
|
2365
2365
|
if (!res.ok || !data.name) {
|
|
2366
2366
|
// A name collision is the common failure, and "already in use" alone
|
|
2367
2367
|
// leaves the user hunting: the holder may be filed in another folder,
|
|
@@ -2425,7 +2425,7 @@ export const SessionSwitcher = ({
|
|
|
2425
2425
|
await fetch("/api/sessions/move", {
|
|
2426
2426
|
method: "POST",
|
|
2427
2427
|
headers: { "Content-Type": "application/json" },
|
|
2428
|
-
body: JSON.stringify({ internalName: data.name, folderId: createInFolder.id })
|
|
2428
|
+
body: JSON.stringify({ internalName: data.internalName || data.name, folderId: createInFolder.id })
|
|
2429
2429
|
}).catch(() => { /* filing is best-effort; the session exists */ });
|
|
2430
2430
|
setCreateInFolder(null);
|
|
2431
2431
|
}
|
|
@@ -2433,8 +2433,10 @@ export const SessionSwitcher = ({
|
|
|
2433
2433
|
// the exact accidental mode-entry the hotkey inversion removed from
|
|
2434
2434
|
// clicks. The new tile becomes current and goes live in place as soon
|
|
2435
2435
|
// as the session reports active.
|
|
2436
|
-
|
|
2437
|
-
|
|
2436
|
+
// The id is minted server-side now; the display name is what the user
|
|
2437
|
+
// typed. Keys ride on the id, labels on the name.
|
|
2438
|
+
onFocusSession?.({ name: data.displayName || data.name, displayName: data.displayName || data.name, active: false, starting: true, internalName: data.internalName || data.name });
|
|
2439
|
+
setFocusedKey(data.internalName || data.name);
|
|
2438
2440
|
onRefresh();
|
|
2439
2441
|
} catch {
|
|
2440
2442
|
onNotice("Failed to create session");
|
|
@@ -9,10 +9,19 @@
|
|
|
9
9
|
// trackpad feel.
|
|
10
10
|
//
|
|
11
11
|
// Safety rules (why this is a util and not three lines):
|
|
12
|
-
// - Normal buffer only. On the alternate screen xterm
|
|
13
|
-
// arrow keys / mouse reports for the app —
|
|
14
|
-
// keystrokes into vim/claude/less after the
|
|
15
|
-
//
|
|
12
|
+
// - Normal buffer only for LOCAL momentum. On the alternate screen xterm
|
|
13
|
+
// translates wheel to arrow keys / mouse reports for the app — local
|
|
14
|
+
// inertia there would spray keystrokes into vim/claude/less after the
|
|
15
|
+
// user stopped scrolling.
|
|
16
|
+
// - In LOCAL scrollback, discrete wheels only (see isDiscreteWheel) —
|
|
17
|
+
// trackpads keep the OS's native glide; doubling it felt drunk.
|
|
18
|
+
// - In APP-OWNED scrolling the rule inverts for trackpads: the OS momentum
|
|
19
|
+
// stream is hundreds of events, each of which xterm turns into a wire
|
|
20
|
+
// mouse-report costing a network round trip. Forwarded 1:1 it floods a
|
|
21
|
+
// remote app into scroll LAG (the transcript keeps moving long after the
|
|
22
|
+
// fingers stop). So there we take ownership: coalesce the stream into
|
|
23
|
+
// line-sized steps and pace them by the app's own acknowledgements —
|
|
24
|
+
// instant when local, matched to the link when remote.
|
|
16
25
|
// - A single notch never glides: momentum engages only for a sustained spin
|
|
17
26
|
// (3+ events in a burst), so precise one-line nudges stay precise.
|
|
18
27
|
// - Any keypress or click kills the glide instantly, and so does hitting
|
|
@@ -107,7 +116,17 @@ export const attachScrollFlywheel = (
|
|
|
107
116
|
// wire format — whatever it sends for a real notch is exactly what it sends
|
|
108
117
|
// for ours.
|
|
109
118
|
const APP_STEPS_PER_NOTCH = 3;
|
|
110
|
-
const APP_MAX_RATE =
|
|
119
|
+
const APP_MAX_RATE = 70; // steps/sec — fast, still ordered
|
|
120
|
+
// Remote scrolling physics: every step is a round trip (report up, full
|
|
121
|
+
// repaint down) and the app repaints ONCE per input batch it drains. So:
|
|
122
|
+
// batch lines per dispatch in proportion to speed (one repaint moves
|
|
123
|
+
// several lines instead of one), and keep a small window of unacked
|
|
124
|
+
// bursts in flight to hide the RTT — serialized one-line-per-RTT was the
|
|
125
|
+
// jerk. Slow speeds still step one line per burst: precision unharmed.
|
|
126
|
+
const APP_ACK_WINDOW = 2; // bursts in flight before we hold
|
|
127
|
+
const APP_MAX_BURST = 5; // lines per burst at full speed
|
|
128
|
+
const burstFor = (vel: number) =>
|
|
129
|
+
Math.max(1, Math.min(APP_MAX_BURST, Math.round(Math.abs(vel) / 20)));
|
|
111
130
|
let appVel = 0;
|
|
112
131
|
let appCarry = 0;
|
|
113
132
|
let appRaf = 0;
|
|
@@ -125,6 +144,9 @@ export const attachScrollFlywheel = (
|
|
|
125
144
|
appVel = 0;
|
|
126
145
|
appCarry = 0;
|
|
127
146
|
appBurst = 0;
|
|
147
|
+
panPx = 0;
|
|
148
|
+
panPending = 0;
|
|
149
|
+
unacked = 0;
|
|
128
150
|
};
|
|
129
151
|
|
|
130
152
|
const stop = () => { stopLocal(); stopApp(); };
|
|
@@ -149,11 +171,24 @@ export const attachScrollFlywheel = (
|
|
|
149
171
|
};
|
|
150
172
|
|
|
151
173
|
let lastStepAt = 0;
|
|
174
|
+
let unacked = 0; // bursts dispatched with no output seen since
|
|
152
175
|
const appGlide = (t: number) => {
|
|
153
176
|
appRaf = 0;
|
|
154
177
|
const term = getTerm();
|
|
155
178
|
// Mode flipped (app exited, screen switched) or run out of speed.
|
|
156
|
-
if (!term || terminalOwnsScrolling(term)
|
|
179
|
+
if (!term || terminalOwnsScrolling(term)) return stopApp();
|
|
180
|
+
if (panPending === 0 && Math.abs(appVel) < coastMin()) {
|
|
181
|
+
// Out of momentum. Rest the machinery but PRESERVE the sub-line
|
|
182
|
+
// accumulation in every case: the quantum is one whole line, and a
|
|
183
|
+
// slow precise drag only crosses it across many events and pauses —
|
|
184
|
+
// any wipe on this path is a low-speed dead zone (shipped one twice).
|
|
185
|
+
// panPx dies only on direction flip, a 2s-still gesture break, a
|
|
186
|
+
// keypress/click kill, or dispose.
|
|
187
|
+
appRaf = 0;
|
|
188
|
+
appVel = 0;
|
|
189
|
+
appCarry = 0;
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
157
192
|
const dt = lastFrameAt ? Math.min(0.05, (t - lastFrameAt) / 1000) : 0.016;
|
|
158
193
|
lastFrameAt = t;
|
|
159
194
|
// ACK pacing: after each synthetic step, wait for the app to answer
|
|
@@ -161,20 +196,128 @@ export const attachScrollFlywheel = (
|
|
|
161
196
|
// the link's real rhythm instead of flooding a slow one. 300ms cap so
|
|
162
197
|
// an app that repaints invisibly (no bytes for us) can't stall the
|
|
163
198
|
// glide forever.
|
|
199
|
+
// Windowed ACK pacing: any output since the last burst clears the
|
|
200
|
+
// window (the app is keeping up); otherwise up to APP_ACK_WINDOW bursts
|
|
201
|
+
// may be in flight before we hold. The 300ms cap keeps an app that
|
|
202
|
+
// repaints invisibly (no bytes for us) from stalling the glide forever.
|
|
164
203
|
const ackAt = opts.lastOutputAt ? opts.lastOutputAt() : Infinity;
|
|
165
|
-
|
|
204
|
+
if (ackAt > lastStepAt) unacked = 0;
|
|
205
|
+
const waitingForAck = lastStepAt > 0 && unacked >= APP_ACK_WINDOW
|
|
206
|
+
&& performance.now() - lastStepAt < 300;
|
|
166
207
|
if (!waitingForAck) {
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
208
|
+
if (panPending !== 0) {
|
|
209
|
+
// Finger-driven lines first: drain the coalesced pan queue in
|
|
210
|
+
// speed-proportional bursts — the app repaints once per burst, so
|
|
211
|
+
// a burst of 4 costs one round trip where 4 singles cost four.
|
|
212
|
+
const spend = Math.sign(panPending)
|
|
213
|
+
* Math.min(Math.abs(panPending), burstFor(appVel || panPending));
|
|
214
|
+
panPending -= spend;
|
|
215
|
+
if (spend !== 0) {
|
|
216
|
+
lastStepAt = performance.now();
|
|
217
|
+
unacked += 1;
|
|
218
|
+
if (appProto) appProto.deltaY = Math.sign(spend) * 120;
|
|
219
|
+
for (let i = 0; i < Math.abs(spend); i++) dispatchStep();
|
|
220
|
+
}
|
|
221
|
+
} else {
|
|
222
|
+
appCarry += Math.min(APP_MAX_RATE, Math.abs(appVel)) * dt;
|
|
223
|
+
const steps = Math.min(Math.trunc(appCarry), burstFor(appVel));
|
|
224
|
+
if (steps > 0) {
|
|
225
|
+
appCarry -= steps;
|
|
226
|
+
lastStepAt = performance.now();
|
|
227
|
+
unacked += 1;
|
|
228
|
+
if (appProto) appProto.deltaY = Math.sign(appVel) * 120;
|
|
229
|
+
for (let i = 0; i < steps; i++) dispatchStep();
|
|
230
|
+
}
|
|
231
|
+
appVel *= Math.exp(-dt / 0.24);
|
|
232
|
+
}
|
|
173
233
|
}
|
|
174
234
|
appRaf = requestAnimationFrame(appGlide);
|
|
175
235
|
};
|
|
176
236
|
|
|
237
|
+
// ---- App-owned TRACKPAD panning -----------------------------------------
|
|
238
|
+
// The finger stream is coalesced into whole-line steps and drained by the
|
|
239
|
+
// same ACK-paced loop the wheel coast uses. panPx carries the sub-line
|
|
240
|
+
// remainder, so slow precise drags still land exactly one step as they
|
|
241
|
+
// cross each line boundary.
|
|
242
|
+
let panPx = 0;
|
|
243
|
+
let panPending = 0; // signed whole steps waiting to be dispatched
|
|
244
|
+
let panLastAt = 0;
|
|
245
|
+
let panMode = false; // last input was a finger stream, not a wheel
|
|
246
|
+
// Trackpads NEVER self-coast: macOS keeps emitting decaying momentum
|
|
247
|
+
// events after the fingers lift — that stream IS the coast, and it feeds
|
|
248
|
+
// the pan queue. Adding our own decay on top gave every flick two glides
|
|
249
|
+
// stacked, which read as overshoot. Only discrete wheels (no OS momentum)
|
|
250
|
+
// earn a synthetic coast, from 4 steps/s past their burst gate.
|
|
251
|
+
const coastMin = () => (panMode ? Infinity : 4);
|
|
252
|
+
|
|
253
|
+
const trackpadPan = (e: WheelEvent) => {
|
|
254
|
+
panMode = true;
|
|
255
|
+
appTarget = e.target;
|
|
256
|
+
// One line's worth of delta per synthetic event, in the pan's direction —
|
|
257
|
+
// xterm emits one wire report per event regardless of magnitude.
|
|
258
|
+
appProto = { deltaY: Math.sign(e.deltaY) * 120, clientX: e.clientX, clientY: e.clientY };
|
|
259
|
+
|
|
260
|
+
const now = e.timeStamp || performance.now();
|
|
261
|
+
const gap = now - panLastAt;
|
|
262
|
+
panLastAt = now;
|
|
263
|
+
const stepPx = Math.max(1, opts.lineHeightPx());
|
|
264
|
+
const px = e.deltaMode === 1 ? e.deltaY * stepPx : e.deltaY;
|
|
265
|
+
// Direction flip: dump everything queued the other way, instantly.
|
|
266
|
+
if (Math.sign(px) !== Math.sign(panPx + panPending) && (panPx !== 0 || panPending !== 0)) {
|
|
267
|
+
panPx = 0;
|
|
268
|
+
panPending = 0;
|
|
269
|
+
appVel = 0;
|
|
270
|
+
}
|
|
271
|
+
// The remainder SURVIVES slow pacing: a precise drag emits small
|
|
272
|
+
// deltas with long gaps, and wiping between them made low-speed
|
|
273
|
+
// scrolling move nothing at all (the quantum is one line; 3px events
|
|
274
|
+
// could never reach it). Only a genuinely new gesture — seconds of
|
|
275
|
+
// stillness — starts from zero.
|
|
276
|
+
if (gap > 2000) { panPx = 0; }
|
|
277
|
+
panPx += px;
|
|
278
|
+
const steps = Math.trunc(panPx / stepPx);
|
|
279
|
+
if (steps !== 0) {
|
|
280
|
+
panPx -= steps * stepPx;
|
|
281
|
+
panPending += steps;
|
|
282
|
+
}
|
|
283
|
+
// Velocity for the post-stream coast, in steps/sec, from the px rate.
|
|
284
|
+
const rate = (px / stepPx) / (Math.min(Math.max(gap, 8), 200) / 1000);
|
|
285
|
+
appVel = Math.sign(rate) === Math.sign(appVel) ? 0.5 * rate + 0.5 * appVel : rate;
|
|
286
|
+
appVel = Math.max(-APP_MAX_RATE, Math.min(APP_MAX_RATE, appVel));
|
|
287
|
+
|
|
288
|
+
// Leading edge dispatches INLINE: a precise first movement must not
|
|
289
|
+
// wait out an animation frame — latency is what precision feels like.
|
|
290
|
+
// The rAF loop takes over for everything after, where pacing matters.
|
|
291
|
+
if (panPending !== 0 && unacked < APP_ACK_WINDOW) {
|
|
292
|
+
const spend = Math.sign(panPending)
|
|
293
|
+
* Math.min(Math.abs(panPending), burstFor(appVel || panPending));
|
|
294
|
+
panPending -= spend;
|
|
295
|
+
lastStepAt = performance.now();
|
|
296
|
+
unacked += 1;
|
|
297
|
+
if (appProto) appProto.deltaY = Math.sign(spend) * 120;
|
|
298
|
+
for (let i = 0; i < Math.abs(spend); i++) dispatchStep();
|
|
299
|
+
}
|
|
300
|
+
if (!appRaf) {
|
|
301
|
+
lastFrameAt = 0;
|
|
302
|
+
appRaf = requestAnimationFrame(appGlide);
|
|
303
|
+
}
|
|
304
|
+
if (appIdle) window.clearTimeout(appIdle);
|
|
305
|
+
appIdle = window.setTimeout(() => {
|
|
306
|
+
appIdle = 0;
|
|
307
|
+
// The OS stream paused. Rest the velocity, but PRESERVE the sub-line
|
|
308
|
+
// remainder — this fires 120ms after every event of a slow drag, and
|
|
309
|
+
// sweeping panPx here was half of the low-speed dead zone. The next
|
|
310
|
+
// event continues the accumulation; the 2s gate above decides when a
|
|
311
|
+
// pause is actually a new gesture.
|
|
312
|
+
if (!appRaf && panPending === 0 && Math.abs(appVel) < coastMin()) {
|
|
313
|
+
appVel = 0;
|
|
314
|
+
appCarry = 0;
|
|
315
|
+
}
|
|
316
|
+
}, 120);
|
|
317
|
+
};
|
|
318
|
+
|
|
177
319
|
const appWheel = (e: WheelEvent) => {
|
|
320
|
+
panMode = false;
|
|
178
321
|
appTarget = e.target;
|
|
179
322
|
appProto = { deltaY: e.deltaY, clientX: e.clientX, clientY: e.clientY };
|
|
180
323
|
if (appRaf) { cancelAnimationFrame(appRaf); appRaf = 0; }
|
|
@@ -220,7 +363,7 @@ export const attachScrollFlywheel = (
|
|
|
220
363
|
term.scrollLines(lines);
|
|
221
364
|
if (term.buffer.active.viewportY === before) return stop(); // hit an edge
|
|
222
365
|
}
|
|
223
|
-
vel *= Math.exp(-dt / 0.
|
|
366
|
+
vel *= Math.exp(-dt / 0.24); // ~240ms decay: brisk, not a runaway
|
|
224
367
|
raf = requestAnimationFrame(glide);
|
|
225
368
|
};
|
|
226
369
|
|
|
@@ -236,8 +379,20 @@ export const attachScrollFlywheel = (
|
|
|
236
379
|
// still does all the scrolling; we only shape the event rate.
|
|
237
380
|
if (!terminalOwnsScrolling(term)) {
|
|
238
381
|
stopLocal();
|
|
239
|
-
|
|
240
|
-
|
|
382
|
+
// Pinch-zoom and sideways pans are not scrolling — leave them alone.
|
|
383
|
+
if (e.ctrlKey || Math.abs(e.deltaX) > Math.abs(e.deltaY)) return;
|
|
384
|
+
if (isDiscreteWheel(e)) {
|
|
385
|
+
appWheel(e);
|
|
386
|
+
} else {
|
|
387
|
+
// Trackpad: take the stream over entirely. Without this, xterm
|
|
388
|
+
// forwards every one of the OS's momentum events as its own wire
|
|
389
|
+
// report and a remote app drowns (scroll lag, overshoot). We
|
|
390
|
+
// preventDefault + stopPropagation the raw events and re-emit
|
|
391
|
+
// line-sized, ACK-paced synthetic ones instead.
|
|
392
|
+
e.preventDefault();
|
|
393
|
+
e.stopPropagation();
|
|
394
|
+
trackpadPan(e);
|
|
395
|
+
}
|
|
241
396
|
return;
|
|
242
397
|
}
|
|
243
398
|
stopApp();
|
|
@@ -255,7 +410,7 @@ export const attachScrollFlywheel = (
|
|
|
255
410
|
const rate = notchLines / (Math.min(Math.max(gap, 30), 200) / 1000);
|
|
256
411
|
// Same direction: blend toward the new rate. Direction flip: hard reset.
|
|
257
412
|
vel = Math.sign(rate) === Math.sign(vel) ? 0.6 * rate + 0.4 * vel : rate;
|
|
258
|
-
const CAP =
|
|
413
|
+
const CAP = 1400; // lines/s — fast, but never a teleport
|
|
259
414
|
vel = Math.max(-CAP, Math.min(CAP, vel));
|
|
260
415
|
|
|
261
416
|
if (idleTimer) window.clearTimeout(idleTimer);
|
|
@@ -272,13 +427,15 @@ export const attachScrollFlywheel = (
|
|
|
272
427
|
};
|
|
273
428
|
|
|
274
429
|
const kill = () => stop();
|
|
275
|
-
// CAPTURE phase
|
|
276
|
-
// preventDefault). Capture is REQUIRED: when the app is tracking the
|
|
430
|
+
// CAPTURE phase. Capture is REQUIRED: when the app is tracking the
|
|
277
431
|
// mouse, xterm's own handler calls stopPropagation() on the way out, so a
|
|
278
432
|
// bubble-phase listener on this ancestor never runs at all — which is why
|
|
279
433
|
// the app-scroll shaping below silently did nothing. Observing first lets
|
|
280
434
|
// xterm do exactly what it always did, and lets us add steps around it.
|
|
281
|
-
|
|
435
|
+
// capture (see below) and NON-passive: the app-owned trackpad branch
|
|
436
|
+
// preventDefaults the raw stream it replaces. Every other path still never
|
|
437
|
+
// cancels anything.
|
|
438
|
+
el.addEventListener("wheel", onWheel, { capture: true, passive: false });
|
|
282
439
|
el.addEventListener("mousedown", kill, true);
|
|
283
440
|
window.addEventListener("keydown", kill, true);
|
|
284
441
|
return () => {
|
|
@@ -111,3 +111,215 @@ describe("attachScrollFlywheel", () => {
|
|
|
111
111
|
dispose();
|
|
112
112
|
});
|
|
113
113
|
});
|
|
114
|
+
|
|
115
|
+
describe("app-owned trackpad panning", () => {
|
|
116
|
+
// A term in the state every Claude session runs: alt screen + tracking.
|
|
117
|
+
const mkAppTerm = () => ({
|
|
118
|
+
scrolled: [] as number[],
|
|
119
|
+
scrollLines() { /* app-owned: local scroll must never happen */ },
|
|
120
|
+
buffer: { active: { type: "alternate", viewportY: 0 } },
|
|
121
|
+
modes: { mouseTrackingMode: "drag" }
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
let el: HTMLElement;
|
|
125
|
+
let rafCbs: FrameRequestCallback[];
|
|
126
|
+
|
|
127
|
+
beforeEach(() => {
|
|
128
|
+
vi.useFakeTimers();
|
|
129
|
+
el = document.createElement("div");
|
|
130
|
+
document.body.appendChild(el);
|
|
131
|
+
rafCbs = [];
|
|
132
|
+
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => (rafCbs.push(cb), rafCbs.length));
|
|
133
|
+
vi.stubGlobal("cancelAnimationFrame", () => { rafCbs = []; });
|
|
134
|
+
});
|
|
135
|
+
afterEach(() => {
|
|
136
|
+
vi.unstubAllGlobals();
|
|
137
|
+
vi.useRealTimers();
|
|
138
|
+
el.remove();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
const pan = (deltaY: number, timeStamp: number, extra: Partial<WheelEvent> = {}) => {
|
|
142
|
+
const e = new Event("wheel", { cancelable: true }) as WheelEvent;
|
|
143
|
+
Object.defineProperties(e, {
|
|
144
|
+
deltaY: { value: deltaY },
|
|
145
|
+
deltaX: { value: (extra as { deltaX?: number }).deltaX ?? 0 },
|
|
146
|
+
ctrlKey: { value: (extra as { ctrlKey?: boolean }).ctrlKey ?? false },
|
|
147
|
+
deltaMode: { value: 0 }, // pixel mode, small deltas: a trackpad
|
|
148
|
+
timeStamp: { value: timeStamp },
|
|
149
|
+
clientX: { value: 10 },
|
|
150
|
+
clientY: { value: 10 }
|
|
151
|
+
});
|
|
152
|
+
el.dispatchEvent(e);
|
|
153
|
+
return e;
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const drainFrames = (frames: number, stepMs = 16) => {
|
|
157
|
+
let t = performance.now();
|
|
158
|
+
for (let i = 0; i < frames && rafCbs.length; i++) {
|
|
159
|
+
const cbs = rafCbs;
|
|
160
|
+
rafCbs = [];
|
|
161
|
+
t += stepMs;
|
|
162
|
+
for (const cb of cbs) cb(t);
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
it("takes ownership of the raw stream and re-emits paced line steps", () => {
|
|
167
|
+
const term = mkAppTerm();
|
|
168
|
+
const dispose = attachScrollFlywheel(el, () => term as never, {
|
|
169
|
+
linesPerNotch: 4,
|
|
170
|
+
lineHeightPx: () => 20
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// Synthetic (our own) wheel events reach the app; count them.
|
|
174
|
+
let forwarded = 0;
|
|
175
|
+
el.addEventListener("wheel", (e) => { if (!e.defaultPrevented) forwarded++; });
|
|
176
|
+
|
|
177
|
+
// A fast flick: 30 raw events of 15px each (0.75 lines apiece).
|
|
178
|
+
let cancelled = 0;
|
|
179
|
+
for (let i = 0; i < 30; i++) {
|
|
180
|
+
const e = pan(15, i * 8);
|
|
181
|
+
if (e.defaultPrevented) cancelled++;
|
|
182
|
+
}
|
|
183
|
+
expect(cancelled).toBe(30); // every RAW event was consumed, none leaked
|
|
184
|
+
// Count arrivals per drained frame to verify pacing.
|
|
185
|
+
let inFrame = 0;
|
|
186
|
+
let maxPerFrame = 0;
|
|
187
|
+
el.addEventListener("wheel", (e) => { if (!e.defaultPrevented) inFrame++; });
|
|
188
|
+
for (let f = 0; f < 40 && rafCbs.length; f++) {
|
|
189
|
+
inFrame = 0;
|
|
190
|
+
drainFrames(1);
|
|
191
|
+
maxPerFrame = Math.max(maxPerFrame, inFrame);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// 450px of finger = 22 whole lines, plus a momentum coast on top (the
|
|
195
|
+
// point of the feature). What must hold: lines flow as bounded BURSTS —
|
|
196
|
+
// at most one speed-proportional burst per frame (each costing the app
|
|
197
|
+
// one repaint), never the raw flood re-emitted wholesale.
|
|
198
|
+
expect(forwarded).toBeGreaterThan(10);
|
|
199
|
+
const maxBurst = 5; // APP_MAX_BURST
|
|
200
|
+
expect(maxPerFrame).toBeLessThanOrEqual(maxBurst);
|
|
201
|
+
expect(term.scrolled.length).toBe(0); // and nothing scrolled locally
|
|
202
|
+
dispose();
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("a slow precise drag lands exactly one step per line crossed", () => {
|
|
206
|
+
const term = mkAppTerm();
|
|
207
|
+
const dispose = attachScrollFlywheel(el, () => term as never, {
|
|
208
|
+
linesPerNotch: 4,
|
|
209
|
+
lineHeightPx: () => 20
|
|
210
|
+
});
|
|
211
|
+
let forwarded = 0;
|
|
212
|
+
el.addEventListener("wheel", (e) => { if (!e.defaultPrevented) forwarded++; });
|
|
213
|
+
|
|
214
|
+
// 5px at a time: three events accumulate 15px — under one line, nothing
|
|
215
|
+
// moves; the fourth crosses 20px and yields exactly one step.
|
|
216
|
+
pan(5, 0); pan(5, 30); pan(5, 60);
|
|
217
|
+
drainFrames(4);
|
|
218
|
+
expect(forwarded).toBe(0);
|
|
219
|
+
pan(5, 90);
|
|
220
|
+
drainFrames(4);
|
|
221
|
+
expect(forwarded).toBe(1);
|
|
222
|
+
dispose();
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("holds after two unacked bursts when the remote goes silent", () => {
|
|
226
|
+
// The pipelining contract: bursts fly while output flows; a remote that
|
|
227
|
+
// stops answering gets at most APP_ACK_WINDOW bursts in flight, then we
|
|
228
|
+
// hold (until the 300ms stall cap) instead of flooding its input queue.
|
|
229
|
+
const term = mkAppTerm();
|
|
230
|
+
let lastOutput = 0; // frozen: the remote never answers
|
|
231
|
+
// A clock that actually advances: the hold logic compares real
|
|
232
|
+
// timestamps, and vitest's frozen performance.now() (0) disarms it.
|
|
233
|
+
let clock = 1000;
|
|
234
|
+
const nowSpy = vi.spyOn(performance, "now").mockImplementation(() => clock);
|
|
235
|
+
const dispose = attachScrollFlywheel(el, () => term as never, {
|
|
236
|
+
linesPerNotch: 4,
|
|
237
|
+
lineHeightPx: () => 20,
|
|
238
|
+
lastOutputAt: () => lastOutput
|
|
239
|
+
});
|
|
240
|
+
let bursts = 0;
|
|
241
|
+
let prevCount = 0;
|
|
242
|
+
let count = 0;
|
|
243
|
+
el.addEventListener("wheel", (e) => { if (!e.defaultPrevented) count++; });
|
|
244
|
+
|
|
245
|
+
for (let i = 0; i < 30; i++) { clock += 8; pan(15, clock); }
|
|
246
|
+
const inFlight = count; // leading-edge bursts, dispatched inline
|
|
247
|
+
expect(inFlight).toBeGreaterThan(0);
|
|
248
|
+
expect(inFlight).toBeLessThanOrEqual(10); // ≤ APP_ACK_WINDOW bursts of ≤5
|
|
249
|
+
|
|
250
|
+
for (let f = 0; f < 15 && rafCbs.length; f++) {
|
|
251
|
+
prevCount = count;
|
|
252
|
+
clock += 16;
|
|
253
|
+
drainFrames(1);
|
|
254
|
+
if (count > prevCount) bursts++;
|
|
255
|
+
}
|
|
256
|
+
// Held: the silent remote gets at most the one stall-cap release, never
|
|
257
|
+
// the queued flood (22 lines are still waiting).
|
|
258
|
+
expect(bursts).toBeLessThanOrEqual(1);
|
|
259
|
+
|
|
260
|
+
// The remote answers: the window clears and bursts resume.
|
|
261
|
+
lastOutput = clock + 1;
|
|
262
|
+
prevCount = count;
|
|
263
|
+
clock += 16;
|
|
264
|
+
drainFrames(3);
|
|
265
|
+
expect(count).toBeGreaterThan(prevCount);
|
|
266
|
+
nowSpy.mockRestore();
|
|
267
|
+
dispose();
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("leaves pinch-zoom and horizontal pans alone", () => {
|
|
271
|
+
const term = mkAppTerm();
|
|
272
|
+
const dispose = attachScrollFlywheel(el, () => term as never, {
|
|
273
|
+
linesPerNotch: 4,
|
|
274
|
+
lineHeightPx: () => 20
|
|
275
|
+
});
|
|
276
|
+
const zoom = pan(15, 0, { ctrlKey: true } as Partial<WheelEvent>);
|
|
277
|
+
const sideways = pan(3, 20, { deltaX: 40 } as Partial<WheelEvent>);
|
|
278
|
+
expect(zoom.defaultPrevented).toBe(false);
|
|
279
|
+
expect(sideways.defaultPrevented).toBe(false);
|
|
280
|
+
dispose();
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it("local-scrollback trackpads keep the native feel — untouched", () => {
|
|
284
|
+
let viewportY = 5000;
|
|
285
|
+
const term = {
|
|
286
|
+
scrolled: [] as number[],
|
|
287
|
+
scrollLines(n: number) { this.scrolled.push(n); viewportY += n; },
|
|
288
|
+
buffer: { active: { type: "normal", get viewportY() { return viewportY; } } }
|
|
289
|
+
};
|
|
290
|
+
const dispose = attachScrollFlywheel(el, () => term as never, {
|
|
291
|
+
linesPerNotch: 4,
|
|
292
|
+
lineHeightPx: () => 20
|
|
293
|
+
});
|
|
294
|
+
const e = pan(15, 0);
|
|
295
|
+
expect(e.defaultPrevented).toBe(false); // xterm's own handling stands
|
|
296
|
+
expect(term.scrolled.length).toBe(0); // and we add no glide of our own
|
|
297
|
+
dispose();
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
it("low-speed scrolling accumulates linearly — no dead zone", () => {
|
|
302
|
+
// The regression: slow drags emit small deltas with LONG gaps, and the
|
|
303
|
+
// accumulator was wiped both by a 250ms gap reset and by the gesture-
|
|
304
|
+
// idle sweep — so low-speed scrolling moved nothing, ever. Friction
|
|
305
|
+
// model: linear accumulation at any speed, one step per line crossed.
|
|
306
|
+
const term = mkAppTerm();
|
|
307
|
+
const dispose = attachScrollFlywheel(el, () => term as never, {
|
|
308
|
+
linesPerNotch: 4,
|
|
309
|
+
lineHeightPx: () => 20
|
|
310
|
+
});
|
|
311
|
+
let forwarded = 0;
|
|
312
|
+
el.addEventListener("wheel", (e) => { if (!e.defaultPrevented) forwarded++; });
|
|
313
|
+
|
|
314
|
+
// 5px every 300ms — a deliberate, slow, precise drag. The idle timer
|
|
315
|
+
// fires between every pair of events.
|
|
316
|
+
for (let i = 0; i < 4; i++) {
|
|
317
|
+
pan(5, i * 300);
|
|
318
|
+
vi.advanceTimersByTime(300); // lets the 120ms idle sweep run
|
|
319
|
+
drainFrames(2);
|
|
320
|
+
}
|
|
321
|
+
// 4 × 5px = 20px = exactly one line: exactly one step, not zero.
|
|
322
|
+
expect(forwarded).toBe(1);
|
|
323
|
+
dispose();
|
|
324
|
+
});
|
|
325
|
+
});
|