hop2 0.11.20 → 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 +41 -12
- package/hay/apps/web/src/utils/scrollFlywheel.ts +177 -20
- package/hay/apps/web/src/utils/switcherModel.ts +7 -1
- package/hay/apps/web/test/SessionSwitcher.test.tsx +33 -0
- package/hay/apps/web/test/scrollFlywheel.test.ts +212 -0
- package/hay/apps/web/test/switcherModel.test.ts +25 -0
- package/hay-web/assets/index-CIfGRbUF.js +134 -0
- package/hay-web/assets/index-CIfGRbUF.js.map +1 -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-DjSB7l7z.js +134 -0
- package/hay-web/assets/index-DjSB7l7z.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 +370 -51
- package/hopa +2 -0
- package/mcp/hop-mcp.js +31 -0
- package/package.json +1 -1
- package/scripts/hay-host.js +32 -1
- package/scripts/salvage-and-restart-host.js +6 -1
- package/skills/hop-session/SKILL.md +25 -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,30 +2361,56 @@ 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,
|
|
2368
2368
|
// or be an agent-created session they never opened. Name it, say
|
|
2369
2369
|
// where it lives, and put it on screen — the answer to "it doesn't
|
|
2370
2370
|
// work" should be the session that was in the way.
|
|
2371
|
+
// Search the WHOLE fleet, not the visible wall: the holder may be
|
|
2372
|
+
// hidden — parked, or an agent-created session on the other tab
|
|
2373
|
+
// (the mybot incident: create "failed", the explanation was a
|
|
2374
|
+
// session the user could not see). A silent 409 with an invisible
|
|
2375
|
+
// cause reads as "it just doesn't work"; the answer must be the
|
|
2376
|
+
// session that was in the way, revealed.
|
|
2371
2377
|
const taken = res.status === 409
|
|
2372
|
-
?
|
|
2378
|
+
? sessions.find((s) => (s.displayName || s.name).toLowerCase() === next.toLowerCase())
|
|
2373
2379
|
: null;
|
|
2374
2380
|
if (taken) {
|
|
2375
2381
|
const home = folders.find((f) => f.id === taken.folderId);
|
|
2376
|
-
|
|
2382
|
+
const visible = wallSessions.some((s) => sessionKey(s) === sessionKey(taken));
|
|
2383
|
+
const hiddenByOrigin = !visibleSessions.some((s) => sessionKey(s) === sessionKey(taken));
|
|
2384
|
+
const where = taken.parked
|
|
2385
|
+
? " — it was hidden among the parked sessions"
|
|
2386
|
+
: hiddenByOrigin
|
|
2387
|
+
? " — it was hidden on the Agent sessions tab"
|
|
2388
|
+
: home ? ` in ${home.name}` : "";
|
|
2389
|
+
onNotice(`"${taken.displayName || taken.name}" already exists${where} — showing it`);
|
|
2377
2390
|
setCreating(false);
|
|
2378
2391
|
setCreateDraft("");
|
|
2379
2392
|
setCreateInFolder(null);
|
|
2393
|
+
// Reveal before focusing: flip whatever filter is hiding it.
|
|
2394
|
+
if (hiddenByOrigin) setOriginScope("all");
|
|
2395
|
+
if (taken.parked) setParkedOpen(true);
|
|
2380
2396
|
const key = sessionKey(taken);
|
|
2381
2397
|
setFocusedKey(key);
|
|
2382
|
-
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
|
|
2398
|
+
if (visible) {
|
|
2399
|
+
const idx = navIndexByKey.get(key);
|
|
2400
|
+
if (typeof idx === "number") {
|
|
2401
|
+
setKbdIndex(idx);
|
|
2402
|
+
requestAnimationFrame(() => {
|
|
2403
|
+
document.querySelector(`[data-nav-index="${idx}"]`)?.scrollIntoView({ block: "center" });
|
|
2404
|
+
});
|
|
2405
|
+
}
|
|
2406
|
+
} else {
|
|
2407
|
+
// It enters the wall on the NEXT render (scope/park state just
|
|
2408
|
+
// changed); scroll once the card exists.
|
|
2409
|
+
window.setTimeout(() => {
|
|
2410
|
+
const el = document.querySelector(`[data-session-key="${CSS.escape(key)}"]`)
|
|
2411
|
+
|| document.querySelector(`[data-nav-index]`);
|
|
2412
|
+
el?.scrollIntoView({ block: "center" });
|
|
2413
|
+
}, 120);
|
|
2388
2414
|
}
|
|
2389
2415
|
return;
|
|
2390
2416
|
}
|
|
@@ -2399,7 +2425,7 @@ export const SessionSwitcher = ({
|
|
|
2399
2425
|
await fetch("/api/sessions/move", {
|
|
2400
2426
|
method: "POST",
|
|
2401
2427
|
headers: { "Content-Type": "application/json" },
|
|
2402
|
-
body: JSON.stringify({ internalName: data.name, folderId: createInFolder.id })
|
|
2428
|
+
body: JSON.stringify({ internalName: data.internalName || data.name, folderId: createInFolder.id })
|
|
2403
2429
|
}).catch(() => { /* filing is best-effort; the session exists */ });
|
|
2404
2430
|
setCreateInFolder(null);
|
|
2405
2431
|
}
|
|
@@ -2407,8 +2433,10 @@ export const SessionSwitcher = ({
|
|
|
2407
2433
|
// the exact accidental mode-entry the hotkey inversion removed from
|
|
2408
2434
|
// clicks. The new tile becomes current and goes live in place as soon
|
|
2409
2435
|
// as the session reports active.
|
|
2410
|
-
|
|
2411
|
-
|
|
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);
|
|
2412
2440
|
onRefresh();
|
|
2413
2441
|
} catch {
|
|
2414
2442
|
onNotice("Failed to create session");
|
|
@@ -2491,6 +2519,7 @@ export const SessionSwitcher = ({
|
|
|
2491
2519
|
role="button"
|
|
2492
2520
|
tabIndex={0}
|
|
2493
2521
|
data-nav-index={navIndexByKey.get(key)}
|
|
2522
|
+
data-session-key={key}
|
|
2494
2523
|
className={`switcher-card${current ? " current" : ""}${activeNow ? " active-now" : ""}${kbdSelected ? " kbd-selected" : ""}${focusedKey === key ? " focused" : ""}${dragKey === key ? " dragging" : ""}${draggable ? " draggable" : ""}`}
|
|
2495
2524
|
draggable={draggable}
|
|
2496
2525
|
onDragStart={draggable ? (e) => { setDragKey(key); e.dataTransfer.effectAllowed = "move"; } : undefined}
|
|
@@ -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 () => {
|
|
@@ -182,7 +182,13 @@ export const filterSessionsByOrigin = (
|
|
|
182
182
|
) => {
|
|
183
183
|
if (scope === "all") return sessions;
|
|
184
184
|
if (scope === "agent") return sessions.filter((session) => session.createdBy === "agent");
|
|
185
|
-
|
|
185
|
+
// The user wall: everything not agent-made — PLUS anything filed in a
|
|
186
|
+
// folder. Folders are the user's own structure, and an agent only files a
|
|
187
|
+
// session into one because the user asked for it there ("create mybot
|
|
188
|
+
// under Softwares", relayed through an agent, landed createdBy=agent and
|
|
189
|
+
// VANISHED from the default wall — created, filed, invisible). Placement
|
|
190
|
+
// in user space outranks provenance.
|
|
191
|
+
return sessions.filter((session) => session.createdBy !== "agent" || !!session.folderId);
|
|
186
192
|
};
|
|
187
193
|
|
|
188
194
|
/**
|
|
@@ -357,3 +357,36 @@ describe("manual folders in the wall", () => {
|
|
|
357
357
|
expect(screen.getByRole("button", { name: /Search full history for/ })).toBeTruthy();
|
|
358
358
|
});
|
|
359
359
|
});
|
|
360
|
+
|
|
361
|
+
describe("create colliding with a hidden session", () => {
|
|
362
|
+
afterEach(() => { vi.unstubAllGlobals(); });
|
|
363
|
+
|
|
364
|
+
it("explains the invisible holder and reveals it instead of failing mutely", async () => {
|
|
365
|
+
// The mybot incident, second act: the name is taken by a session the
|
|
366
|
+
// user cannot SEE (agent-created, loose, other tab). A bare "already in
|
|
367
|
+
// use" against an empty wall reads as "create silently did nothing".
|
|
368
|
+
const holder: SwitcherSession = {
|
|
369
|
+
name: "mybot", displayName: "mybot", internalName: "mybot",
|
|
370
|
+
active: true, starting: false, createdBy: "agent"
|
|
371
|
+
};
|
|
372
|
+
const onNotice = vi.fn();
|
|
373
|
+
vi.stubGlobal("fetch", vi.fn(async () => ({
|
|
374
|
+
ok: false,
|
|
375
|
+
status: 409,
|
|
376
|
+
json: async () => ({ error: "Session name already in use" })
|
|
377
|
+
})));
|
|
378
|
+
|
|
379
|
+
render(<SessionSwitcher {...props} onNotice={onNotice} sessions={[...sessions, holder]} open />);
|
|
380
|
+
// The holder is genuinely hidden on the default (user) tab.
|
|
381
|
+
expect(screen.queryByText("mybot")).toBeNull();
|
|
382
|
+
|
|
383
|
+
fireEvent.click(screen.getByRole("button", { name: "New session" }));
|
|
384
|
+
fireEvent.change(screen.getByLabelText("New session name"), { target: { value: "mybot" } });
|
|
385
|
+
fireEvent.submit(screen.getByLabelText("New session name").closest("form")!);
|
|
386
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
387
|
+
|
|
388
|
+
expect(onNotice).toHaveBeenCalledWith(expect.stringMatching(/already exists.*hidden.*Agent/i));
|
|
389
|
+
// And the wall now SHOWS it — scope flipped so the answer is on screen.
|
|
390
|
+
expect(screen.getByText("mybot")).toBeTruthy();
|
|
391
|
+
});
|
|
392
|
+
});
|